diff --git a/server.js b/server.js index d1fe915..8d09456 100644 --- a/server.js +++ b/server.js @@ -614,13 +614,16 @@ app.get("/whatsapp/status", authMiddleware, (req, res, next) => requirePlan(req, // 馃 MOTOR INTELIGENTE DE AGENDAMIENTO (RUTAS NUEVAS) // ========================================== -// 1. Obtener huecos disponibles inteligentes +// ========================================== +// 馃 MOTOR INTELIGENTE DE AGENDAMIENTO Y AGENDA ADMIN +// ========================================== + +// 1. Obtener huecos disponibles inteligentes (Bloqueando por duraci贸n) app.get("/public/portal/:token/slots", async (req, res) => { try { const { token } = req.params; const { serviceId } = req.query; - // 1. Validar cliente y servicio 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" }); @@ -631,14 +634,14 @@ app.get("/public/portal/:token/slots", async (req, res) => { const assignedTo = service.assigned_to; if (!assignedTo) return res.status(400).json({ ok: false, error: "No hay operario asignado" }); - // 2. Extraer "Zona" del servicio actual (Poblaci贸n o CP) para agrupar const raw = service.raw_data || {}; const targetZone = (raw["Poblaci贸n"] || raw["POBLACION-PROVINCIA"] || raw["C贸digo Postal"] || "").toLowerCase().trim(); - // 3. Buscar la agenda del operario para los pr贸ximos 14 d铆as + // Extraemos la agenda respetando la duraci贸n (duration_minutes) 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 FROM scraped_services @@ -647,43 +650,43 @@ app.get("/public/portal/:token/slots", async (req, res) => { AND raw_data->>'scheduled_date' >= CURRENT_DATE::text `, [assignedTo]); - // Mapa de d铆as ocupados y sus zonas "Ancla" const agendaMap = {}; agendaQ.rows.forEach(row => { if (!agendaMap[row.date]) agendaMap[row.date] = { times: [], zone: (row.poblacion || row.cp || "").toLowerCase().trim() }; - agendaMap[row.date].times.push(row.time); + + // Calculamos cu谩ntas horas bloquea este servicio seg煤n su duraci贸n + const dur = parseInt(row.duration || 60); // Por defecto 1 hora + if (row.time) { + const startHour = parseInt(row.time.split(':')[0]); + const hoursBlocked = Math.ceil(dur / 60); + for (let i = 0; i < hoursBlocked; i++) { + const h = startHour + i; + agendaMap[row.date].times.push(h.toString().padStart(2, '0') + ":00"); + } + } }); - // 4. Generar pr贸ximos 10 d铆as laborables const availableDays = []; let d = new Date(); - d.setDate(d.getDate() + 1); // Empezamos desde ma帽ana + d.setDate(d.getDate() + 1); let daysAdded = 0; while(daysAdded < 10) { - // Saltamos domingos - if (d.getDay() !== 0) { - const dateStr = d.toISOString().split('T')[0]; // YYYY-MM-DD + if (d.getDay() !== 0) { // Omitir domingos + const dateStr = d.toISOString().split('T')[0]; const dayData = agendaMap[dateStr]; - let isDayAllowed = true; - // REGLA DE AGRUPACI脫N (EL CEREBRO): - // Si el d铆a ya tiene servicios, comprobamos si la zona coincide. if (dayData && dayData.zone && targetZone) { - // Si el d铆a est谩 anclado a una zona diferente, bloqueamos el d铆a para este cliente if (!dayData.zone.includes(targetZone) && !targetZone.includes(dayData.zone)) { isDayAllowed = false; } } if (isDayAllowed) { - // Generar Huecos. Ma帽ana: 9 a 13 | Tarde: 16 a 19 const morningSlots = ["09:00", "10:00", "11:00", "12:00", "13:00"]; const afternoonSlots = ["16:00", "17:00", "18:00", "19:00"]; - const takenTimes = dayData ? dayData.times : []; - const availMorning = morningSlots.filter(t => !takenTimes.includes(t)); const availAfternoon = afternoonSlots.filter(t => !takenTimes.includes(t)); @@ -700,12 +703,11 @@ app.get("/public/portal/:token/slots", async (req, res) => { } d.setDate(d.getDate() + 1); } - res.json({ ok: true, days: availableDays }); - } catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); } + } catch (e) { res.status(500).json({ ok: false }); } }); -// 2. Guardar la cita elegida por el cliente +// 2. Guardar la cita como "SOLICITUD PENDIENTE" app.post("/public/portal/:token/book", async (req, res) => { const client = await pool.connect(); try { @@ -713,7 +715,6 @@ app.post("/public/portal/:token/book", async (req, res) => { const { serviceId, date, time } = req.body; await client.query('BEGIN'); - const clientQ = await client.query("SELECT owner_id FROM clients WHERE portal_token = $1", [token]); if (clientQ.rowCount === 0) throw new Error("Token inv谩lido"); const ownerId = clientQ.rows[0].owner_id; @@ -723,26 +724,102 @@ app.post("/public/portal/:token/book", async (req, res) => { const raw = serviceQ.rows[0].raw_data; - // Buscamos el estado "Citado" - const statusQ = await client.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [ownerId]); - const idCitado = statusQ.rows[0]?.id || raw.status_operativo; - - const updatedRaw = { ...raw, scheduled_date: date, scheduled_time: time, status_operativo: idCitado }; + // Guardamos las fechas requeridas pero NO asignamos a煤n la cita real + const updatedRaw = { + ...raw, + requested_date: date, + requested_time: time, + appointment_status: 'pending' + }; await client.query("UPDATE scraped_services SET raw_data = $1 WHERE id = $2", [JSON.stringify(updatedRaw), serviceId]); - - // Disparamos el WhatsApp informando de la cita - await triggerWhatsAppEvent(ownerId, serviceId, 'wa_evt_date'); - await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); - console.error("Error Booking:", e); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); +// 3. OBTENER SOLICITUDES PARA EL PANEL DEL ADMIN +app.get("/agenda/requests", authMiddleware, async (req, res) => { + try { + const q = await pool.query(` + SELECT s.id, s.service_ref, s.raw_data, u.full_name as assigned_name + FROM scraped_services s + LEFT JOIN users u ON s.assigned_to = u.id + WHERE s.owner_id = $1 + AND s.raw_data->>'appointment_status' = 'pending' + ORDER BY s.created_at ASC + `, [req.user.accountId]); + res.json({ ok: true, requests: q.rows }); + } catch (e) { res.status(500).json({ ok: false }); } +}); + +// 4. APROBAR CITA (Aqu铆 se establece la duraci贸n y se env铆a WA) +app.post("/agenda/requests/:id/approve", authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { duration } = req.body; // En minutos + + const current = await pool.query('SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]); + if (current.rowCount === 0) return res.status(404).json({ok: false}); + + const raw = current.rows[0].raw_data; + const reqDate = raw.requested_date; + const reqTime = raw.requested_time; + + const statusQ = await pool.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND name ILIKE '%citado%' LIMIT 1", [req.user.accountId]); + const idCitado = statusQ.rows[0]?.id || raw.status_operativo; + + const updatedRaw = { + ...raw, + scheduled_date: reqDate, + scheduled_time: reqTime, + duration_minutes: duration, + appointment_status: 'approved', + status_operativo: idCitado + }; + delete updatedRaw.requested_date; + delete updatedRaw.requested_time; + + await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]); + + // Disparamos WhatsApp oficial de cita confirmada + await triggerWhatsAppEvent(req.user.accountId, id, 'wa_evt_date'); + res.json({ok: true}); + } catch (e) { res.status(500).json({ok: false}); } +}); + +// 5. RECHAZAR CITA +app.post("/agenda/requests/:id/reject", authMiddleware, async (req, res) => { + try { + const { id } = req.params; + const current = await pool.query('SELECT raw_data FROM scraped_services WHERE id=$1 AND owner_id=$2', [id, req.user.accountId]); + if (current.rowCount === 0) return res.status(404).json({ok: false}); + + const raw = current.rows[0].raw_data; + const updatedRaw = { ...raw, appointment_status: 'rejected' }; + delete updatedRaw.requested_date; + delete updatedRaw.requested_time; + + await pool.query("UPDATE scraped_services SET raw_data=$1 WHERE id=$2", [JSON.stringify(updatedRaw), id]); + + // Enviar WA de rechazo con el enlace para que elija otra + const phone = raw["Tel茅fono"] || raw["TELEFONO"] || ""; + if (phone) { + const clientQ = await pool.query("SELECT portal_token FROM clients WHERE phone LIKE $1 AND owner_id=$2 LIMIT 1", [`%${phone.replace('+34', '').trim()}%`, req.user.accountId]); + const token = clientQ.rows[0]?.portal_token; + const link = `https://portal.integrarepara.es/?token=${token}&service=${id}`; + + const finalMsg = `鈿狅笍 *CITA NO CONFIRMADA*\n\nHola ${raw["Nombre Cliente"] || "Cliente"}. Lamentamos informarte que el t茅cnico no podr谩 acudir en el horario que solicitaste por un problema de ruta.\n\nPor favor, entra de nuevo en tu portal y elige otro hueco disponible:\n馃敆 ${link}`; + + await sendWhatsAppAuto(phone, finalMsg, `cliente_${req.user.accountId}`, false); + } + res.json({ok: true}); + } catch (e) { res.status(500).json({ok: false}); } +}); + // ========================================== // 鈿欙笍 MOTOR AUTOM脕TICO DE WHATSAPP // ==========================================