// AGENTFORGE-IA — Agendado operativo: Google Workspace + citas persistidas const Scheduling = () => { const { t, lang } = useI18n(); const token = sessionStorage.getItem('af_token') || ''; const notify = (msg) => window.afNotify && window.afNotify(msg); const [view, setView] = React.useState('month'); const [cursorDate, setCursorDate] = React.useState(new Date()); const [googleConn, setGoogleConn] = React.useState({ connected: false, google_email: null, source: 'none' }); const [events, setEvents] = React.useState([]); const [loadingEvents, setLoadingEvents] = React.useState(false); const [openEventId, setOpenEventId] = React.useState(null); const [showModal, setShowModal] = React.useState(false); const [datePickerOpen, setDatePickerOpen] = React.useState(false); const [datePickerMonth, setDatePickerMonth] = React.useState(new Date()); const [saving, setSaving] = React.useState(false); const [eventsVersion, setEventsVersion] = React.useState(0); const [titleError, setTitleError] = React.useState(false); const [draft, setDraft] = React.useState({ title: '', event_type: 'demo', date: '', startTime: '10:00', duration: 30, attendee_name: '', attendee_email: '', location: '', description: '', }); const types = React.useMemo(() => [ { id: 'demo', name: lang === 'en' ? 'Sales demo' : 'Demo comercial', dur: 30, color: '#3b82f6' }, { id: 'discovery', name: lang === 'en' ? 'Discovery call' : 'Llamada de descubrimiento', dur: 20, color: '#10b981' }, { id: 'support', name: lang === 'en' ? 'Support session' : 'Sesión de soporte', dur: 15, color: '#f59e0b' }, { id: 'qbr', name: lang === 'en' ? 'Quarterly review' : 'Revisión trimestral', dur: 60, color: '#8b5cf6' }, { id: 'consulting', name: lang === 'en' ? 'Paid consulting' : 'Consultoría de pago', dur: 45, color: '#ec4899' }, ], [lang]); const calendars = [ { name: 'Google Workspace', sub: googleConn.connected ? (googleConn.google_email || (lang === 'en' ? 'Connected' : 'Conectado')) : (lang === 'en' ? 'Not connected' : 'No conectado'), logo: 'G', color: '#4285F4', status: googleConn.connected ? 'ok' : 'off', }, { name: lang === 'en' ? 'Internal appointments' : 'Reservas internas', sub: lang === 'en' ? 'Persisted in AgentForge database' : 'Persistidas en la base de datos de AgentForge', logo: 'AF', color: '#ff5b1f', status: 'ok', }, ]; const today = new Date(); const monthEs = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']; const monthEn = ['January','February','March','April','May','June','July','August','September','October','November','December']; const dayNames = lang === 'en' ? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] : ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; const hours = Array.from({ length: 13 }, (_, i) => 8 + i); const hourPx = 56; const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }; const endOfDay = (d) => { const x = new Date(d); x.setHours(23, 59, 59, 999); return x; }; const startOfWeek = (d) => { const x = startOfDay(d); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; }; const endOfWeek = (d) => { const x = startOfWeek(d); x.setDate(x.getDate() + 6); return endOfDay(x); }; const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0); const endOfMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); const isoDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const localDateTime = (d) => `${isoDate(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:00`; const parseIsoDate = (value) => { if (!value) return null; const [year, month, day] = value.split('-').map(Number); if (!year || !month || !day) return null; return new Date(year, month - 1, day); }; const formatDateInput = (value) => { const d = parseIsoDate(value); if (!d) return ''; return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; }; const formatLongDate = (date) => { const months = lang === 'en' ? monthEn : monthEs; const names = lang === 'en' ? ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] : ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']; return `${names[date.getDay()]}, ${date.getDate()} ${lang === 'en' ? 'of' : 'de'} ${months[date.getMonth()]}`; }; const periodStart = React.useMemo(() => { if (view === 'day') return startOfDay(cursorDate); if (view === 'month') return startOfMonth(cursorDate); return startOfWeek(cursorDate); }, [view, cursorDate]); const periodEnd = React.useMemo(() => { if (view === 'day') return endOfDay(cursorDate); if (view === 'month') return endOfMonth(cursorDate); return endOfWeek(cursorDate); }, [view, cursorDate]); const days = React.useMemo(() => { const length = view === 'day' ? 1 : 7; if (view === 'month') return []; return Array.from({ length }, (_, i) => { const d = new Date(periodStart); d.setDate(periodStart.getDate() + i); return d; }); }, [view, periodStart]); const apiFetch = (url, options = {}) => fetch(url, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}), }, }); const rangeLabel = () => { const start = periodStart; const end = periodEnd; const months = lang === 'en' ? monthEn : monthEs; if (view === 'day') return `${start.getDate()} ${months[start.getMonth()]} ${start.getFullYear()}`; if (view === 'month') return `${months[start.getMonth()]} ${start.getFullYear()}`; if (start.getMonth() !== end.getMonth()) { return `${start.getDate()} ${months[start.getMonth()]} - ${end.getDate()} ${months[end.getMonth()]} ${start.getFullYear()}`; } return `${start.getDate()} - ${end.getDate()} ${months[start.getMonth()]} ${start.getFullYear()}`; }; const mapEvent = (item) => { const startDate = new Date(item.start); const endDate = new Date(item.end); if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) return null; if (startDate > periodEnd || endDate < periodStart) return null; const base = view === 'day' ? startOfDay(periodStart) : startOfWeek(periodStart); const day = Math.floor((startOfDay(startDate).getTime() - base.getTime()) / 86400000); const start = startDate.getHours() + (startDate.getMinutes() / 60); const dur = Math.max(0.25, (endDate.getTime() - startDate.getTime()) / 3600000); return { id: item.id, day, start, dur, type: item.type || item.event_type || 'demo', title: item.title || (lang === 'en' ? 'Appointment' : 'Cita'), sub: item.attendee_name || item.attendee_email || (item.provider === 'google' ? 'Google Calendar' : 'AgentForge'), by: item.provider === 'google' ? (item.source_email || googleConn.google_email || 'Google Workspace') : (lang === 'en' ? 'Internal appointment' : 'Reserva interna'), html_link: item.html_link || null, description: item.description || '', location: item.location || '', provider: item.provider || 'local', attendee_email: item.attendee_email || '', attendee_name: item.attendee_name || '', startDate, endDate, }; }; const loadGoogleStatus = async () => { if (!token) return; try { const res = await apiFetch('/api/v1/scheduling/google/status'); if (!res.ok) return; const data = await res.json(); setGoogleConn(data || { connected: false, google_email: null, source: 'none' }); } catch (_) {} }; const loadEvents = async () => { if (!token) return; setLoadingEvents(true); try { const res = await apiFetch(`/api/v1/scheduling/events?time_min=${encodeURIComponent(periodStart.toISOString())}&time_max=${encodeURIComponent(periodEnd.toISOString())}`); if (!res.ok) { setEvents([]); return; } const data = await res.json(); setGoogleConn((prev) => ({ ...prev, connected: Boolean(data.google_connected), google_email: data.google_email || prev.google_email, })); setEvents((data.items || []).map(mapEvent).filter(Boolean)); } catch (_) { setEvents([]); } finally { setLoadingEvents(false); } }; React.useEffect(() => { loadGoogleStatus(); }, []); React.useEffect(() => { loadEvents(); }, [view, periodStart.getTime(), periodEnd.getTime(), eventsVersion]); React.useEffect(() => { const params = new URLSearchParams(window.location.search); const oauthError = params.get('error'); if (oauthError) { notify(lang === 'en' ? 'Google rejected the account. Use an authorised Workspace account.' : 'Google rechazó la cuenta. Usa una cuenta autorizada del Workspace.'); window.history.replaceState({}, document.title, window.location.pathname); return; } const code = params.get('code'); const state = params.get('state'); if (!code || !state || !token) return; (async () => { try { const res = await apiFetch(`/api/v1/scheduling/google/exchange?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, { method: 'POST' }); if (!res.ok) throw new Error(); notify(lang === 'en' ? 'Google Workspace connected' : 'Google Workspace conectado'); await loadGoogleStatus(); await loadEvents(); window.history.replaceState({}, document.title, window.location.pathname); } catch (_) { window.history.replaceState({}, document.title, window.location.pathname); notify(lang === 'en' ? 'Google OAuth failed. Use an authorised Workspace account.' : 'Falló Google OAuth. Usa una cuenta autorizada del Workspace.'); } })(); }, [token]); const open = React.useMemo(() => { if (!openEventId) return null; const event = events.find((e) => e.id === openEventId); if (!event) return null; const ty = types.find((x) => x.id === event.type) || types[0]; return { ...event, ty }; }, [events, openEventId, types]); const moveRange = (dir) => { setCursorDate((prev) => { const next = new Date(prev); if (view === 'day') next.setDate(prev.getDate() + dir); else if (view === 'month') next.setMonth(prev.getMonth() + dir); else next.setDate(prev.getDate() + (dir * 7)); return next; }); }; const openNewAppointment = () => { const selectedType = types.find((x) => x.id === draft.event_type) || types[0]; const date = isoDate(cursorDate); setDraft((prev) => ({ ...prev, date, duration: selectedType.dur, })); setTitleError(false); setDatePickerMonth(parseIsoDate(date) || cursorDate); setDatePickerOpen(false); setShowModal(true); }; const saveEvent = async () => { if (!draft.title.trim()) { setTitleError(true); return; } if (!draft.date || !draft.startTime) { notify(lang === 'en' ? 'Complete date and time' : 'Completa fecha y hora'); return; } setSaving(true); try { const start = new Date(`${draft.date}T${draft.startTime}:00`); const end = new Date(start.getTime() + (Number(draft.duration || 30) * 60000)); const body = { title: draft.title.trim(), event_type: draft.event_type, start: `${draft.date}T${draft.startTime}:00`, end: localDateTime(end), timezone: 'Europe/Madrid', attendee_name: draft.attendee_name.trim() || null, attendee_email: draft.attendee_email.trim() || null, location: draft.location.trim() || null, description: draft.description.trim(), }; const res = await apiFetch('/api/v1/scheduling/events', { method: 'POST', body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); if (!res.ok) { const msg = Array.isArray(data.detail) ? data.detail.map((e) => e.msg || JSON.stringify(e)).join('; ') : (data.detail || (lang === 'en' ? 'Could not create appointment' : 'No se pudo crear la cita')); throw new Error(msg); } setShowModal(false); setDraft((prev) => ({ ...prev, title: '', attendee_name: '', attendee_email: '', location: '', description: '' })); notify(data.warning || (data.google_synced ? 'Cita creada y sincronizada con Google' : 'Cita creada en AgentForge')); setEventsVersion((v) => v + 1); } catch (error) { notify(error.message || (lang === 'en' ? 'Error creating appointment' : 'Error creando la cita')); } finally { setSaving(false); } }; const changeType = (eventType) => { const ty = types.find((x) => x.id === eventType) || types[0]; setDraft((prev) => ({ ...prev, event_type: eventType, duration: ty.dur })); }; /** * Sincroniza la botonera lateral con el borrador para que no sea decorativa. */ const selectAppointmentType = (eventType) => { changeType(eventType); const ty = types.find((x) => x.id === eventType) || types[0]; notify(lang === 'en' ? `${ty.name} selected (${ty.dur} min)` : `${ty.name} seleccionado (${ty.dur} min)`); }; /** * Selector de fecha visual con comportamiento tipo calendario de Windows. */ const DatePicker = ({ value, onChange }) => { const selectedDate = parseIsoDate(value) || today; const monthDate = datePickerMonth || selectedDate; const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); const monthOffset = monthStart.getDay() === 0 ? 6 : monthStart.getDay() - 1; const gridStart = new Date(monthStart); gridStart.setDate(monthStart.getDate() - monthOffset); const monthNames = lang === 'en' ? monthEn : monthEs; const cells = Array.from({ length: 42 }, (_, index) => { const d = new Date(gridStart); d.setDate(gridStart.getDate() + index); return d; }); const chooseDate = (date) => { onChange(isoDate(date)); setCursorDate(date); setDatePickerMonth(date); setDatePickerOpen(false); }; const movePickerMonth = (delta) => { setDatePickerMonth((prev) => { const next = new Date(prev || selectedDate); next.setMonth(next.getMonth() + delta); return next; }); }; return (
{datePickerOpen && (
e.stopPropagation()}>
{monthNames[monthDate.getMonth()]} {monthDate.getFullYear()}
{(lang === 'en' ? ['M','T','W','T','F','S','S'] : ['L','M','X','J','V','S','D']).map((day, idx) => {day})}
{cells.map((date) => { const current = isoDate(date); const isSelected = current === value; const isToday = current === isoDate(today); const muted = date.getMonth() !== monthDate.getMonth(); return ( ); })}
)}
); }; const monthLeadingCells = React.useMemo(() => { const first = new Date(cursorDate.getFullYear(), cursorDate.getMonth(), 1); return first.getDay() === 0 ? 6 : first.getDay() - 1; }, [cursorDate]); return (

{t('page.scheduling')}

{t('page.scheduling.crumbs')} {t('page.scheduling.summary')}
{lang === 'en' ? 'Connected calendars' : 'Calendarios conectados'}
{calendars.map((c, i) => (
{c.logo}
{c.name}
{c.sub}
))}
{lang === 'en' ? 'Appointment types' : 'Tipos de cita'}
{types.map((ty) => ( ))}

{rangeLabel()}

{[ ['day', lang === 'en' ? 'Day' : 'Día'], ['week', lang === 'en' ? 'Week' : 'Semana'], ['month', lang === 'en' ? 'Month' : 'Mes'], ].map(([id, label]) => ( ))}
{view !== 'month' && (
 
{hours.map((h) =>
{String(h).padStart(2,'0')}:00
)}
{days.map((d, di) => (
{dayNames[(d.getDay() + 6) % 7]} {d.getDate()}
))} {days.map((d, di) => (
{hours.map((h) =>
)} {events.filter((e) => e.day === di && e.start < 21 && (e.start + e.dur) > 8).map((e) => { const ty = types.find((x) => x.id === e.type) || types[0]; const top = Math.max(0, (e.start - 8) * hourPx); const height = Math.max(24, e.dur * hourPx); return (
setOpenEventId(e.id)} style={{ top, height, background: `${ty.color}26`, borderLeftColor: ty.color, color: 'var(--ink)' }}>
{e.title}
{e.sub}
{height >= 38 &&
{e.provider === 'google' ? 'Google' : 'AgentForge'} · {Math.round(e.dur * 60)} min
}
); })}
))}
)} {view === 'month' && (
{dayNames.map((name) =>
{name}
)} {Array.from({ length: monthLeadingCells }).map((_, i) =>
)} {Array.from({ length: new Date(cursorDate.getFullYear(), cursorDate.getMonth() + 1, 0).getDate() }, (_, idx) => { const dayNum = idx + 1; const dayEvts = events.filter((e) => e.startDate.getDate() === dayNum && e.startDate.getMonth() === cursorDate.getMonth() && e.startDate.getFullYear() === cursorDate.getFullYear()); return (
{dayNum}
{dayEvts.slice(0, 3).map((ev) => { const ty = types.find((x) => x.id === ev.type) || types[0]; return
setOpenEventId(ev.id)} style={{ borderLeftColor: ty.color }}>{ev.title}
; })} {dayEvts.length > 3 &&
+{dayEvts.length - 3}
}
); })}
)} {loadingEvents &&
{lang === 'en' ? 'Loading events…' : 'Cargando citas…'}
} {!loadingEvents && events.length === 0 &&
{lang === 'en' ? 'No appointments in this period yet.' : 'Aún no hay citas en este periodo.'}
}
{open && ( <>
setOpenEventId(null)} />

{open.title}

{open.sub} · {open.ty.name} · {Math.round(open.dur * 60)} min
{lang === 'en' ? 'Provider' : 'Proveedor'}
{open.provider === 'google' ? 'Google Workspace' : 'AgentForge'}
{lang === 'en' ? 'Start' : 'Inicio'}
{open.startDate.toLocaleString(lang === 'en' ? 'en-GB' : 'es-ES')}
{lang === 'en' ? 'End' : 'Fin'}
{open.endDate.toLocaleString(lang === 'en' ? 'en-GB' : 'es-ES')}
{open.attendee_email &&
Email
{open.attendee_email}
} {open.location &&
{lang === 'en' ? 'Location' : 'Ubicación'}
{open.location}
} {open.description &&
{lang === 'en' ? 'Notes' : 'Notas'}
{open.description}
} {open.html_link && }
)} {showModal && (
{ if (!saving) { setShowModal(false); setTitleError(false); } }}>
e.stopPropagation()}>

{lang === 'en' ? 'New appointment' : 'Nueva cita'}

{ setTitleError(false); setDraft((p) => ({ ...p, title: e.target.value })); }} style={titleError ? { borderColor: 'var(--err, #ef4444)', boxShadow: '0 0 0 2px rgba(239,68,68,0.2)' } : undefined} /> {titleError &&
{lang === 'en' ? 'Title is required' : 'El título es obligatorio'}
}
setDraft((p) => ({ ...p, date }))} /> setDraft((p) => ({ ...p, startTime: e.target.value }))} />
setDraft((p) => ({ ...p, duration: Number(e.target.value || 30) }))} />
setDraft((p) => ({ ...p, attendee_name: e.target.value }))} /> setDraft((p) => ({ ...p, attendee_email: e.target.value }))} />
setDraft((p) => ({ ...p, location: e.target.value }))} />