/opt/canhelp/apps/web/src/app/schedule
NameSizeModeActions
page.tsx199430644editdlrm
Edit: /opt/canhelp/apps/web/src/app/schedule/page.tsx (19943B)
'use client' import { useState, useEffect, useCallback } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from '@/lib/auth' import { useLocale } from '@/context/locale' import { getMyAvailability, setAvailability, setAvailabilityRange, request } from '@/lib/api' type DayStatus = 'available' | 'busy' | null const STATUS_CYCLE: Record = { available: 'busy', busy: null, } const WEEK_DAYS_BY_LOCALE: Record = { ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'], en: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], el: ['Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σά', 'Κυ'], } const MONTHS_BY_LOCALE: Record = { ru: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'], en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], el: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'], } function toMonthKey(year: number, month: number) { return `${year}-${String(month + 1).padStart(2, '0')}` } function toDateKey(year: number, month: number, day: number) { return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` } export default function SchedulePage() { const { data: session, isPending } = useSession() const router = useRouter() const searchParams = useSearchParams() const { locale, t } = useLocale() const now = new Date() const [viewYear, setViewYear] = useState(now.getFullYear()) const [viewMonth, setViewMonth] = useState(now.getMonth()) // availability map: 'YYYY-MM-DD' → 'available'|'busy' const [avail, setAvail] = useState>({}) const [saving, setSaving] = useState(null) // date being saved // Range selection state const [rangeStart, setRangeStart] = useState(null) const [rangeMode, setRangeMode] = useState(false) const [rangeStatus, setRangeStatus] = useState<'available' | 'busy'>('available') // Google Calendar state const [gcalStatus, setGcalStatus] = useState<{ connected: boolean; email?: string | null } | null>(null) const [gcalLoading, setGcalLoading] = useState(false) const [gcalToast, setGcalToast] = useState(null) const today = new Date().toISOString().slice(0, 10) useEffect(() => { if (!isPending && !session) router.push('/login') if (!isPending && session && (session.user as any)?.role !== 'specialist' && (session.user as any)?.role !== 'admin') router.push('/profile') }, [session, isPending]) // Load Google Calendar status const loadGcalStatus = useCallback(() => { request<{ connected: boolean; email?: string | null }>('/google/calendar/status') .then(setGcalStatus) .catch(() => setGcalStatus({ connected: false })) }, []) useEffect(() => { if (!session) return loadGcalStatus() // Handle callback redirect params const gcalParam = searchParams.get('gcal') if (gcalParam === 'connected') { setGcalToast(t('schedule.gcalConnected')) setTimeout(() => setGcalToast(null), 5000) router.replace('/schedule') } else if (gcalParam === 'error') { setGcalToast(t('schedule.gcalError')) setTimeout(() => setGcalToast(null), 5000) router.replace('/schedule') } }, [session]) const loadMonth = useCallback((year: number, month: number) => { const key = toMonthKey(year, month) getMyAvailability(key) .then((rows) => { setAvail((prev) => { const next = { ...prev } rows.forEach((r: any) => { next[r.date] = r.status }) return next }) }) .catch(() => {}) }, []) useEffect(() => { if (!session) return loadMonth(viewYear, viewMonth) // preload next month too const next = new Date(viewYear, viewMonth + 1, 1) loadMonth(next.getFullYear(), next.getMonth()) }, [session, viewYear, viewMonth, loadMonth]) function prevMonth() { if (viewMonth === 0) { setViewYear((y) => y - 1); setViewMonth(11) } else setViewMonth((m) => m - 1) } function nextMonth() { if (viewMonth === 11) { setViewYear((y) => y + 1); setViewMonth(0) } else setViewMonth((m) => m + 1) } async function toggleDay(dateKey: string) { if (dateKey < today) return // past — ignore if (rangeMode) { if (!rangeStart) { setRangeStart(dateKey) return } // Second click — apply range const from = rangeStart < dateKey ? rangeStart : dateKey const to = rangeStart < dateKey ? dateKey : rangeStart setRangeStart(null) setSaving('range') try { await setAvailabilityRange(from, to, rangeStatus) // Update local state setAvail((prev) => { const next = { ...prev } const cur = new Date(from) const end = new Date(to) while (cur <= end) { const d = cur.toISOString().slice(0, 10) if (d >= today) next[d] = rangeStatus cur.setDate(cur.getDate() + 1) } return next }) } catch { /* */ } setSaving(null) return } const current = avail[dateKey] ?? null const next: DayStatus = current === null ? 'available' : STATUS_CYCLE[current] setSaving(dateKey) // Optimistic setAvail((prev) => ({ ...prev, [dateKey]: next })) try { await setAvailability(dateKey, next) } catch { // Revert on error setAvail((prev) => ({ ...prev, [dateKey]: current })) } setSaving(null) } if (isPending) return
{t('common.loading')}
const role = (session?.user as any)?.role if (!session || (role !== 'specialist' && role !== 'admin')) return null const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' async function connectGcal() { window.location.href = `${apiBase}/api/google/calendar/connect` } async function disconnectGcal() { if (!confirm(t('schedule.gcalDisconnectConfirm'))) return setGcalLoading(true) try { await request('/google/calendar/disconnect', { method: 'DELETE' }) setGcalStatus({ connected: false }) } catch {} setGcalLoading(false) } async function importFromGcal() { setGcalLoading(true) try { const res = await request<{ ok: boolean; imported: number }>('/google/calendar/import', { method: 'POST' }) setGcalToast(t('schedule.gcalImportDone').replace('{n}', String((res as any).imported ?? 0))) setTimeout(() => setGcalToast(null), 5000) // Refresh calendar display loadMonth(viewYear, viewMonth) } catch {} setGcalLoading(false) } return (
{/* Toast notification */} {gcalToast && (
{gcalToast}
)} {/* Header */}

{t('schedule.title')}

{t('schedule.subtitle')}

{/* Legend */}
{t('schedule.available')}
{t('schedule.busy')}
{t('schedule.unset')}
{t('schedule.click_hint')}
{/* Range mode toggle */}
{rangeMode && (
{rangeStart && ( {t('schedule.range_from').replace('{date}', rangeStart)} )}
)}
{/* Calendar */} {/* Google Calendar panel */}
{/* Google Calendar icon */}

{t('schedule.googleCalendar')}

{gcalStatus === null ? (
{t('common.loading')}
) : gcalStatus.connected ? (
{/* Connected checkmark */} {t('schedule.connectedAs')} {gcalStatus.email}

{t('schedule.gcalAutoSyncNote')}

) : (

{t('schedule.gcalConnectDesc')}

)}
) } function AvailCalendar({ year, month, avail, saving, rangeStart, today, onToggle, onPrev, onNext, }: { year: number month: number avail: Record saving: string | null rangeStart: string | null today: string onToggle: (date: string) => void onPrev: () => void onNext: () => void }) { const { locale, t } = useLocale() const weekDays = WEEK_DAYS_BY_LOCALE[locale] ?? WEEK_DAYS_BY_LOCALE.ru const months = MONTHS_BY_LOCALE[locale] ?? MONTHS_BY_LOCALE.ru const firstDay = new Date(year, month, 1) // Monday-based: 0=Mon, 6=Sun const startDow = (firstDay.getDay() + 6) % 7 const daysInMonth = new Date(year, month + 1, 0).getDate() const prevMonthDays = new Date(year, month, 0).getDate() const cells: Array<{ day: number; cur: boolean; dateKey: string }> = [] // Leading days from prev month for (let i = startDow - 1; i >= 0; i--) { cells.push({ day: prevMonthDays - i, cur: false, dateKey: '' }) } // Current month days for (let d = 1; d <= daysInMonth; d++) { cells.push({ day: d, cur: true, dateKey: toDateKey(year, month, d) }) } // Trailing days const remainder = cells.length % 7 if (remainder !== 0) { for (let d = 1; d <= 7 - remainder; d++) { cells.push({ day: d, cur: false, dateKey: '' }) } } // Count summary — only future dates (>= today) const monthPrefix = toDateKey(year, month, 1).slice(0, 7) const availCount = Object.entries(avail).filter(([k, v]) => k.startsWith(monthPrefix) && k >= today && v === 'available').length const busyCount = Object.entries(avail).filter(([k, v]) => k.startsWith(monthPrefix) && k >= today && v === 'busy').length return (
{/* Month header */}

{months[month]} {year}

{availCount} {t('schedule.free')} {' · '} {busyCount} {t('schedule.busy_count')}

{/* Day headers */}
{weekDays.map((d) => (
{d}
))}
{/* Cells */}
{cells.map((cell, idx) => { if (!cell.cur) { return (
{cell.day}
) } const status = avail[cell.dateKey] ?? null const isPast = cell.dateKey < today const isToday = cell.dateKey === today const isSaving = saving === cell.dateKey const isRangeStart = rangeStart === cell.dateKey let cellCls = 'h-12 flex flex-col items-center justify-center rounded-xl m-0.5 text-sm font-medium transition-all select-none ' if (isPast) { cellCls += 'text-gray-200 cursor-default ' } else if (status === 'available') { cellCls += 'bg-emerald-100 border-2 border-emerald-400 text-emerald-700 cursor-pointer hover:bg-emerald-200 ' } else if (status === 'busy') { cellCls += 'bg-red-100 border-2 border-red-400 text-red-700 cursor-pointer hover:bg-red-200 ' } else { cellCls += 'bg-gray-50 border border-gray-200 text-gray-700 cursor-pointer hover:bg-green-50 hover:border-green-300 ' } if (isToday) cellCls += 'ring-2 ring-blue-400 ring-offset-1 ' if (isRangeStart) cellCls += '!ring-2 !ring-green-500 !ring-offset-1 scale-105 ' if (isSaving) cellCls += 'opacity-60 pointer-events-none ' return ( ) })}
) }