'use client'
import { useState, useEffect, useMemo } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2, Eye, EyeOff, CalendarDays, User, AlignLeft, List } from 'lucide-react'
interface CalendarEvent {
id: string
title: string
start: string
end: string
allDay: boolean
description: string | null
location: string | null
owner: string
ownerName: string
color: string
}
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
function formatFullDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
}
// ————— Add Event Modal —————
const CALENDAR_OPTIONS = [
{ owner: 'daniil', name: 'Даниил', color: '#6366f1' },
{ owner: 'sveta', name: 'Света', color: '#ec4899' },
]
function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; onClose: () => void; onSaved: (e: any) => void }) {
const [title, setTitle] = useState('')
const [date, setDate] = useState(defaultDate)
const [startTime, setStartTime] = useState('10:00')
const [endTime, setEndTime] = useState('11:00')
const [allDay, setAllDay] = useState(false)
const [owner, setOwner] = useState('daniil')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const selectedCal = CALENDAR_OPTIONS.find(c => c.owner === owner) || CALENDAR_OPTIONS[0]
const save = async () => {
if (!title.trim()) { setError('Введите название'); return }
setSaving(true); setError('')
try {
const body = { title: title.trim(), date, startTime: allDay ? null : startTime, endTime: allDay ? null : endTime, allDay, owner }
const r = await fetch('/api/calendar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json()
if (d.error) throw new Error(d.error)
onSaved(d.event)
} catch (e: any) { setError(e.message || 'Ошибка сохранения'); setSaving(false) }
}
return (
e.stopPropagation()}>
{/* Header */}
Новое событие
{/* Body */}
{/* Calendar selector */}
{CALENDAR_OPTIONS.map(cal => {
const sel = owner === cal.owner
return (
)
})}
{/* Title */}
setTitle(e.target.value)}
placeholder="Название события"
autoFocus
style={{
width: '100%', padding: '15px 18px', borderRadius: 14, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 500,
outline: 'none', fontFamily: 'inherit',
marginBottom: 16,
}}
/>
{/* Date */}
Дата
setDate(e.target.value)}
style={{
width: '100%', padding: '14px 18px', borderRadius: 14, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 15,
outline: 'none', fontFamily: 'inherit',
}}
/>
{/* All day toggle */}
{/* Time pickers */}
{!allDay && (
Время
setStartTime(e.target.value)}
style={{
flex: 1, padding: '14px 18px', border: 'none',
background: 'transparent', color: 'var(--text-primary)',
fontSize: 16, fontWeight: 600, fontFamily: 'inherit',
outline: 'none', textAlign: 'center',
}}
/>
до
setEndTime(e.target.value)}
style={{
flex: 1, padding: '14px 18px', border: 'none',
background: 'transparent', color: 'var(--text-primary)',
fontSize: 16, fontWeight: 600, fontFamily: 'inherit',
outline: 'none', textAlign: 'center',
}}
/>
)}
{/* Error */}
{error && (
{error}
)}
{/* Submit */}
)
}
// ————— Event Detail Modal —————
function EventDetailModal({ event, onClose, onDelete, onUpdate }: {
event: CalendarEvent
onClose: () => void
onDelete: (e: CalendarEvent) => Promise
onUpdate: (old: CalendarEvent, updated: Partial) => Promise
}) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [editing, setEditing] = useState(false)
const [editTitle, setEditTitle] = useState(event.title)
const [editDate, setEditDate] = useState(
event.start.includes('T') ? event.start.split('T')[0] : event.start
)
const [editStartTime, setEditStartTime] = useState(
event.start.includes('T') ? formatTime(event.start) : '10:00'
)
const [editEndTime, setEditEndTime] = useState(
event.end.includes('T') ? formatTime(event.end) : '11:00'
)
const [editAllDay, setEditAllDay] = useState(event.allDay)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const handleDelete = async () => {
setDeleting(true)
await onDelete(event)
setDeleting(false)
}
const handleSave = async () => {
if (!editTitle.trim()) { setSaveError('Введите название'); return }
setSaving(true); setSaveError('')
try {
await onUpdate(event, {
title: editTitle.trim(),
start: editAllDay ? editDate : `${editDate}T${editStartTime}`,
end: editAllDay ? editDate : `${editDate}T${editEndTime}`,
allDay: editAllDay,
})
} catch (e: any) {
setSaveError(e.message || 'Ошибка сохранения')
setSaving(false)
}
}
return (
e.stopPropagation()}>
{/* Colored header */}
{editing ? (
setEditTitle(e.target.value)}
autoFocus
style={{
width: '100%', padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)',
color: 'var(--text-primary)', fontSize: 18, fontWeight: 700,
outline: 'none', fontFamily: 'inherit',
}}
/>
) : (
{event.title}
)}
{!editing && (
)}
{/* Content */}
{editing ? (
<>
{/* Date */}
Дата
setEditDate(e.target.value)} style={{
width: '100%', padding: '12px 16px', borderRadius: 12, boxSizing: 'border-box' as any,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 15, outline: 'none', fontFamily: 'inherit',
}} />
{/* All day toggle */}
{/* Time */}
{!editAllDay && (
Время
setEditStartTime(e.target.value)} style={{
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
}} />
до
setEditEndTime(e.target.value)} style={{
flex: 1, padding: '12px 16px', border: 'none', background: 'transparent',
color: 'var(--text-primary)', fontSize: 16, fontWeight: 600,
fontFamily: 'inherit', outline: 'none', textAlign: 'center',
}} />
)}
{saveError && (
{saveError}
)}
{/* Save / Cancel */}
>
) : (
<>
{/* View mode */}
{formatFullDate(event.start)}
{event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`}
{event.location && (
)}
{event.description && (
)}
{/* Delete */}
{!confirmDelete ? (
) : (
)}
>
)}
)
}
// ————— Day Events Popover —————
function DayEventsModal({ day, month, year, events, onClose, onSelect }: {
day: number; month: number; year: number
events: CalendarEvent[]
onClose: () => void
onSelect: (e: CalendarEvent) => void
}) {
const date = new Date(year, month, day)
const label = date.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
return (
e.stopPropagation()}>
{label}
{events.map(e => (
))}
)
}
// ————— Main Calendar —————
export default function CalendarTab() {
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().getMonth())
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(true)
const [selectedEvent, setSelectedEvent] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const [addDate, setAddDate] = useState('')
const [hiddenOwners, setHiddenOwners] = useState>(new Set())
const [dayPopover, setDayPopover] = useState<{ day: number; events: CalendarEvent[] } | null>(null)
const [showUpcoming, setShowUpcoming] = useState(false)
useEffect(() => {
setLoading(true)
fetch('/api/calendar?range=month&year=' + year + '&month=' + month)
.then(r => r.json())
.then(d => { setEvents(d.events || []); setLoading(false) })
.catch(() => setLoading(false))
}, [year, month])
const deleteEvent = async (event: CalendarEvent) => {
try {
const r = await fetch(`/api/calendar?eventId=${event.id}`, { method: 'DELETE' })
const d = await r.json()
if (d.error) throw new Error(d.error)
setEvents(prev => prev.filter(e => e.id !== event.id))
setSelectedEvent(null)
} catch (e: any) { alert(e.message || 'Ошибка удаления') }
}
const updateEvent = async (event: CalendarEvent, updates: Partial) => {
const startStr = updates.start || event.start
const endStr = updates.end || event.end
const body = {
eventId: event.id,
title: updates.title || event.title,
date: startStr.split('T')[0],
startTime: updates.allDay ? null : formatTime(startStr),
endTime: updates.allDay ? null : formatTime(endStr),
allDay: updates.allDay ?? event.allDay,
}
const r = await fetch('/api/calendar', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json()
if (d.error) throw new Error(d.error)
setEvents(prev => prev.map(e => e.id === event.id ? {
...e,
title: updates.title || e.title,
start: d.event.start || e.start,
end: d.event.end || e.end,
allDay: d.event.allDay ?? e.allDay,
} : e))
setSelectedEvent(null)
}
const calendarOwners = useMemo(() => {
const map = new Map()
events.forEach(e => {
const existing = map.get(e.owner)
if (existing) existing.count++
else map.set(e.owner, { owner: e.owner, ownerName: e.ownerName, color: e.color, count: 1 })
})
return Array.from(map.values())
}, [events])
const toggleOwner = (owner: string) => {
setHiddenOwners(prev => {
const next = new Set(prev)
if (next.has(owner)) next.delete(owner)
else next.add(owner)
return next
})
}
const filteredEvents = useMemo(() => {
if (hiddenOwners.size === 0) return events
return events.filter(e => !hiddenOwners.has(e.owner))
}, [events, hiddenOwners])
const upcoming = filteredEvents
.filter(e => new Date(e.start) >= new Date())
.slice(0, 6)
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startOffset = (firstDay.getDay() + 6) % 7
const totalCells = Math.ceil((startOffset + lastDay.getDate()) / 7) * 7
const cells: (number | null)[] = []
for (let i = 0; i < totalCells; i++) {
const dayNum = i - startOffset + 1
cells.push(dayNum >= 1 && dayNum <= lastDay.getDate() ? dayNum : null)
}
const getEventsForDay = (day: number) => {
return filteredEvents.filter(e => {
const d = new Date(e.start)
return d.getFullYear() === year && d.getMonth() === month && d.getDate() === day
})
}
const prevMonth = () => { if (month === 0) { setMonth(11); setYear(y => y - 1) } else setMonth(m => m - 1) }
const nextMonth = () => { if (month === 11) { setMonth(0); setYear(y => y + 1) } else setMonth(m => m + 1) }
const today = new Date()
const handleDayClick = (day: number) => {
const dayEvents = getEventsForDay(day)
if (dayEvents.length === 0) {
setAddDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`)
setShowAddModal(true)
} else if (dayEvents.length === 1) {
setSelectedEvent(dayEvents[0])
} else {
setDayPopover({ day, events: dayEvents })
}
}
return (
{/* Main calendar grid */}
{/* Header */}
{MONTHS[month]} {year}
{calendarOwners.map(cal => {
const isHidden = hiddenOwners.has(cal.owner)
return (
)
})}
{/* Weekday headers */}
{WEEKDAYS.map((d, i) => (
= 5 ? 'rgba(248,113,113,0.5)' : 'var(--text-secondary)',
textTransform: 'uppercase', padding: '6px 0', letterSpacing: '0.05em',
}}>{d}
))}
{/* Calendar cells */}
{cells.map((day, idx) => {
if (!day) return
const dayEvents = getEventsForDay(day)
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day
const dayOfWeek = (startOffset + day - 1) % 7
const isWeekend = dayOfWeek >= 5
return (
handleDayClick(day)}
style={{
borderRadius: 12, padding: '4px 5px',
background: isToday ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.08))' : 'rgba(255,255,255,0.015)',
border: isToday ? '1px solid rgba(129,140,248,0.3)' : '1px solid transparent',
cursor: 'pointer', minHeight: 70,
display: 'flex', flexDirection: 'column', gap: 3,
transition: 'all 0.2s ease',
}}
>
{day}
{dayEvents.slice(0, 3).map(e => (
{ ev.stopPropagation(); setSelectedEvent(e) }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
background: `${e.color}18`,
borderLeft: `3px solid ${e.color}`,
borderRadius: '0 6px 6px 0',
padding: '4px 6px',
cursor: 'pointer',
overflow: 'hidden',
}}
>
{!e.allDay && (
{formatTime(e.start)}
)}
{e.title}
))}
{dayEvents.length > 3 && (
+{dayEvents.length - 3}
)}
)
})}
{/* Right panel — overlay */}
{showUpcoming &&
setShowUpcoming(false)} />}
Ближайшие
{upcoming.length === 0 && !loading && (
Нет событий
)}
{upcoming.map(e => {
const d = new Date(e.start)
return (
setSelectedEvent(e)} style={{
borderRadius: 16, padding: '14px',
background: `${e.color}0c`, border: `1px solid ${e.color}1a`,
cursor: 'pointer', transition: 'all 0.25s ease',
}}>
{d.getDate()}
{MONTHS[d.getMonth()].slice(0, 3)}
{e.allDay ? 'Весь день' : formatTime(e.start)}
{e.title}
{e.ownerName}
)
})}
{/* Modals */}
{selectedEvent && (
setSelectedEvent(null)} onDelete={deleteEvent} onUpdate={updateEvent} />
)}
{dayPopover && (
setDayPopover(null)}
onSelect={e => { setDayPopover(null); setSelectedEvent(e) }}
/>
)}
{showAddModal && (
setShowAddModal(false)}
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
/>
)}
)
}