/opt/canhelp/apps/web/src/app/schedule
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 (
)
})}
)
}