/
opt
/
canhelp
/
apps
/
web
/
src
/
app
/
schedule
/
/opt/canhelp/apps/web/src/app/schedule
mkdir
upload
Name
Size
Mode
Actions
page.tsx
19943
0644
edit
dl
rm
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<string, DayStatus> = { available: 'busy', busy: null, } const WEEK_DAYS_BY_LOCALE: Record<string, string[]> = { ru: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'], en: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'], el: ['Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σά', 'Κυ'], } const MONTHS_BY_LOCALE: Record<string, string[]> = { 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<Record<string, DayStatus>>({}) const [saving, setSaving] = useState<string | null>(null) // date being saved // Range selection state const [rangeStart, setRangeStart] = useState<string | null>(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<string | null>(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 <div className="text-center py-20 text-gray-500">{t('common.loading')}</div> 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 ( <div className="max-w-3xl mx-auto px-4 py-10"> {/* Toast notification */} {gcalToast && ( <div className="fixed top-4 right-4 z-50 bg-white border border-gray-200 shadow-lg rounded-xl px-4 py-3 text-sm text-gray-800 max-w-xs"> {gcalToast} </div> )} {/* Header */} <div className="mb-8"> <h1 className="text-2xl font-bold text-white">{t('schedule.title')}</h1> <p className="text-sm text-white/70 mt-1"> {t('schedule.subtitle')} </p> </div> {/* Legend */} <div className="flex flex-wrap gap-4 mb-6"> <div className="flex items-center gap-2 text-sm text-white/80"> <span className="w-6 h-6 rounded-lg bg-emerald-100 border-2 border-emerald-400 flex items-center justify-center text-xs">✓</span> {t('schedule.available')} </div> <div className="flex items-center gap-2 text-sm text-white/80"> <span className="w-6 h-6 rounded-lg bg-red-100 border-2 border-red-400 flex items-center justify-center text-xs">✗</span> {t('schedule.busy')} </div> <div className="flex items-center gap-2 text-sm text-white/80"> <span className="w-6 h-6 rounded-lg bg-white/20 border border-white/30" /> {t('schedule.unset')} </div> <div className="text-xs text-white/50 self-center">{t('schedule.click_hint')}</div> </div> {/* Range mode toggle */} <div className="flex items-center gap-3 mb-6 p-3 bg-white rounded-xl border border-gray-200"> <label className="flex items-center gap-2 cursor-pointer"> <div onClick={() => { setRangeMode((v) => !v); setRangeStart(null) }} className={`w-10 h-6 rounded-full transition-colors relative cursor-pointer ${rangeMode ? 'bg-green-500' : 'bg-gray-200'}`} > <span className={`absolute top-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${rangeMode ? 'translate-x-5' : 'translate-x-1'}`} /> </div> <span className="text-sm font-medium text-gray-700">{t('schedule.range_mode')}</span> </label> {rangeMode && ( <div className="flex items-center gap-2 ml-4"> <button onClick={() => setRangeStatus('available')} className={`px-3 py-1 rounded-lg text-sm font-medium border transition ${rangeStatus === 'available' ? 'bg-emerald-100 border-emerald-400 text-emerald-700' : 'bg-white border-gray-200 text-gray-500'}`} > {t('schedule.available')} </button> <button onClick={() => setRangeStatus('busy')} className={`px-3 py-1 rounded-lg text-sm font-medium border transition ${rangeStatus === 'busy' ? 'bg-red-100 border-red-400 text-red-700' : 'bg-white border-gray-200 text-gray-500'}`} > {t('schedule.busy')} </button> {rangeStart && ( <span className="text-xs text-green-600"> {t('schedule.range_from').replace('{date}', rangeStart)} </span> )} </div> )} </div> {/* Calendar */} <AvailCalendar year={viewYear} month={viewMonth} avail={avail} saving={saving} rangeStart={rangeStart} today={today} onToggle={toggleDay} onPrev={prevMonth} onNext={nextMonth} /> {/* Google Calendar panel */} <div className="mt-6 bg-white rounded-2xl border border-gray-200 p-5 shadow-sm"> <div className="flex items-center gap-2 mb-4"> {/* Google Calendar icon */} <svg width="22" height="22" viewBox="0 0 48 48" fill="none"> <rect width="48" height="48" rx="8" fill="#fff"/> <path d="M34 14H14a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2V16a2 2 0 0 0-2-2z" fill="#4285F4"/> <path d="M28 20h-8v2h8v-2zm0 4h-8v2h8v-2zm-2 4h-6v2h6v-2z" fill="#fff"/> <circle cx="18" cy="18" r="2" fill="#DB4437"/> <circle cx="30" cy="18" r="2" fill="#DB4437"/> <rect x="17" y="12" width="2" height="6" rx="1" fill="#DB4437"/> <rect x="29" y="12" width="2" height="6" rx="1" fill="#DB4437"/> </svg> <h3 className="font-semibold text-gray-900 text-sm">{t('schedule.googleCalendar')}</h3> </div> {gcalStatus === null ? ( <div className="text-xs text-gray-400">{t('common.loading')}</div> ) : gcalStatus.connected ? ( <div className="space-y-3"> <div className="flex items-center gap-2"> {/* Connected checkmark */} <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <circle cx="8" cy="8" r="8" fill="#22c55e"/> <path d="M5 8l2 2 4-4" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> </svg> <span className="text-sm text-gray-700"> {t('schedule.connectedAs')} <span className="font-medium">{gcalStatus.email}</span> </span> </div> <p className="text-xs text-gray-400">{t('schedule.gcalAutoSyncNote')}</p> <div className="flex flex-wrap gap-2"> <button onClick={importFromGcal} disabled={gcalLoading} className="px-3 py-1.5 rounded-lg border border-green-200 bg-green-50 text-green-700 text-xs font-medium hover:bg-green-100 disabled:opacity-50 transition" > {gcalLoading ? t('common.loading') : t('schedule.importFromGoogle')} </button> <button onClick={disconnectGcal} disabled={gcalLoading} className="px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-500 text-xs font-medium hover:bg-gray-50 disabled:opacity-50 transition" > {t('schedule.disconnectGoogle')} </button> </div> </div> ) : ( <div className="space-y-3"> <p className="text-xs text-gray-500">{t('schedule.gcalConnectDesc')}</p> <button onClick={connectGcal} className="flex items-center gap-2 px-4 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 transition text-sm font-medium text-gray-700" > <svg width="16" height="16" viewBox="0 0 48 48" fill="none"> <path d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z" fill="#FFC107"/> <path d="M6.3 14.7l7 5.1C15 16.1 19.1 13 24 13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 16.3 2 9.7 7.4 6.3 14.7z" fill="#FF3D00"/> <path d="M24 46c5.5 0 10.5-1.9 14.4-5.1l-6.7-5.5C29.5 37 26.9 38 24 38c-6.1 0-11.2-4-13-9.5l-7 5.4C7.6 41.9 15.2 46 24 46z" fill="#4CAF50"/> <path d="M44.5 20H24v8.5h11.8c-.9 2.8-2.8 5.1-5.3 6.6l6.7 5.5C41.5 37.3 45 31.1 45 24c0-1.3-.2-2.7-.5-4z" fill="#1976D2"/> </svg> {t('schedule.connectGoogle')} </button> </div> )} </div> </div> ) } function AvailCalendar({ year, month, avail, saving, rangeStart, today, onToggle, onPrev, onNext, }: { year: number month: number avail: Record<string, DayStatus> 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 ( <div className="bg-white rounded-2xl border border-gray-200 overflow-hidden shadow-sm"> {/* Month header */} <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100"> <button onClick={onPrev} className="w-8 h-8 rounded-lg hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" > ‹ </button> <div className="text-center"> <h2 className="font-semibold text-gray-900"> {months[month]} {year} </h2> <p className="text-xs text-gray-400 mt-0.5"> <span className="text-emerald-600 font-medium">{availCount} {t('schedule.free')}</span> {' · '} <span className="text-red-500 font-medium">{busyCount} {t('schedule.busy_count')}</span> </p> </div> <button onClick={onNext} className="w-8 h-8 rounded-lg hover:bg-gray-100 flex items-center justify-center text-gray-500 transition" > › </button> </div> {/* Day headers */} <div className="grid grid-cols-7 border-b border-gray-100"> {weekDays.map((d) => ( <div key={d} className="py-2 text-center text-xs font-medium text-gray-400"> {d} </div> ))} </div> {/* Cells */} <div className="grid grid-cols-7"> {cells.map((cell, idx) => { if (!cell.cur) { return ( <div key={`empty-${idx}`} className="p-1 h-12 flex items-center justify-center text-sm text-gray-200"> {cell.day} </div> ) } 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 ( <button key={cell.dateKey} className={cellCls} onClick={() => onToggle(cell.dateKey)} disabled={isPast || isSaving} title={status === 'available' ? t('schedule.available') : status === 'busy' ? t('schedule.busy') : t('schedule.click_to_set')} > <span>{cell.day}</span> {!isPast && status === 'available' && <span className="text-[9px] leading-none text-emerald-600">{t('schedule.available').toLowerCase()}</span>} {!isPast && status === 'busy' && <span className="text-[9px] leading-none text-red-500">{t('schedule.busy').toLowerCase()}</span>} {isSaving && <span className="text-[8px] text-gray-400">...</span>} </button> ) })} </div> </div> ) }
Save
cmd:
run