Files
smart-home-tablet/components/CalendarTab.tsx
Cosmo 690db4c6cf
Some checks failed
Deploy / deploy (push) Has been cancelled
feat: event editing, light/dark theme, device animations, 7-day forecast
2026-04-22 19:56:38 +00:00

876 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (
<div style={{
position: 'fixed', inset: 0,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)',
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}} onClick={onClose}>
<div style={{
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
width: 420, maxWidth: '95vw', overflow: 'hidden',
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
}} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{
padding: '24px 28px 20px',
borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
Новое событие
</span>
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(255,255,255,0.05)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)',
}}>
<X size={16} />
</button>
</div>
{/* Body */}
<div style={{ padding: '20px 28px 28px' }}>
{/* Calendar selector */}
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{CALENDAR_OPTIONS.map(cal => {
const sel = owner === cal.owner
return (
<button key={cal.owner} onClick={() => setOwner(cal.owner)} style={{
flex: 1, padding: '11px 0', borderRadius: 12,
background: sel ? `${cal.color}15` : 'rgba(255,255,255,0.03)',
border: `1.5px solid ${sel ? cal.color + '40' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'all 0.2s ease',
}}>
<div style={{
width: 10, height: 10, borderRadius: '50%',
background: sel ? cal.color : 'rgba(255,255,255,0.15)',
boxShadow: sel ? `0 0 8px ${cal.color}50` : 'none',
transition: 'all 0.2s ease',
}} />
<span style={{
fontSize: 14, fontWeight: sel ? 600 : 400,
color: sel ? cal.color : 'var(--text-secondary)',
}}>{cal.name}</span>
</button>
)
})}
</div>
{/* Title */}
<input
value={title} onChange={e => setTitle(e.target.value)}
placeholder="Название события"
autoFocus
style={{
width: '100%', padding: '15px 18px', borderRadius: 14,
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 */}
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: 8,
}}>Дата</div>
<input
type="date" value={date} onChange={e => setDate(e.target.value)}
style={{
width: '100%', padding: '14px 18px', borderRadius: 14,
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',
}}
/>
</div>
{/* All day toggle */}
<button onClick={() => setAllDay(v => !v)} style={{
width: '100%', padding: '13px 18px', borderRadius: 14,
background: allDay ? `${selectedCal.color}10` : 'rgba(255,255,255,0.025)',
border: `1px solid ${allDay ? selectedCal.color + '25' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16, transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: 14, fontWeight: 500,
color: allDay ? selectedCal.color : 'var(--text-secondary)',
}}>Весь день</span>
<div style={{
width: 40, height: 22, borderRadius: 11,
background: allDay ? selectedCal.color : 'rgba(255,255,255,0.1)',
position: 'relative', transition: 'background 0.2s ease',
}}>
<div style={{
width: 18, height: 18, borderRadius: '50%',
background: '#fff', position: 'absolute', top: 2,
left: allDay ? 20 : 2,
transition: 'left 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
</button>
{/* Time pickers */}
{!allDay && (
<div style={{ marginBottom: 16 }}>
<div style={{
fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: 8,
}}>Время</div>
<div style={{
display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 14, overflow: 'hidden',
}}>
<input
type="time" value={startTime} onChange={e => 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',
}}
/>
<div style={{
padding: '0 12px', color: 'var(--text-tertiary)',
fontSize: 13, fontWeight: 500, flexShrink: 0,
borderLeft: '1px solid rgba(255,255,255,0.06)',
borderRight: '1px solid rgba(255,255,255,0.06)',
height: '100%', display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.02)',
}}>до</div>
<input
type="time" value={endTime} onChange={e => 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',
}}
/>
</div>
</div>
)}
{/* Error */}
{error && (
<div style={{
padding: '10px 14px', borderRadius: 12, marginBottom: 16,
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.12)',
color: '#f87171', fontSize: 13, fontWeight: 500,
}}>{error}</div>
)}
{/* Submit */}
<button onClick={save} disabled={saving} style={{
width: '100%', padding: '15px', borderRadius: 14,
background: saving
? 'rgba(255,255,255,0.04)'
: `linear-gradient(135deg, ${selectedCal.color}45, ${selectedCal.color}30)`,
border: `1px solid ${selectedCal.color}35`,
color: selectedCal.color === '#ec4899' ? '#f9a8d4' : '#c7d2fe',
fontSize: 15, fontWeight: 700,
cursor: saving ? 'default' : 'pointer',
transition: 'all 0.25s ease',
boxShadow: saving ? 'none' : `0 4px 16px ${selectedCal.color}18`,
}}>
{saving ? 'Сохранение...' : 'Создать'}
</button>
</div>
</div>
</div>
)
}
// ————— Event Detail Modal —————
function EventDetailModal({ event, onClose, onDelete, onUpdate }: {
event: CalendarEvent
onClose: () => void
onDelete: (e: CalendarEvent) => Promise<void>
onUpdate: (old: CalendarEvent, updated: Partial<CalendarEvent>) => Promise<void>
}) {
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 (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(12px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{
background: 'rgba(16,16,30,0.97)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.07)', borderRadius: 28,
width: 480, maxWidth: '95vw', overflow: 'hidden',
boxShadow: '0 30px 90px rgba(0,0,0,0.6)',
}} onClick={e => e.stopPropagation()}>
{/* Colored header */}
<div style={{
background: `linear-gradient(135deg, ${event.color}20, ${event.color}08)`,
borderBottom: `1px solid ${event.color}15`,
padding: '24px 28px 20px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<input
value={editTitle} onChange={e => 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',
}}
/>
) : (
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.3 }}>
{event.title}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: event.color, boxShadow: `0 0 8px ${event.color}60` }} />
<span style={{ fontSize: 13, color: event.color, fontWeight: 600 }}>{event.ownerName}</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 12, flexShrink: 0 }}>
{!editing && (
<button onClick={() => setEditing(true)} style={{
padding: '8px 14px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
color: 'var(--text-secondary)', fontSize: 12, fontWeight: 600,
}}>
Изменить
</button>
)}
<button onClick={onClose} style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)',
}}>
<X size={16} />
</button>
</div>
</div>
</div>
{/* Content */}
<div style={{ padding: '20px 28px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{editing ? (
<>
{/* Date */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Дата</div>
<input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} style={{
width: '100%', padding: '12px 16px', borderRadius: 12,
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',
}} />
</div>
{/* All day toggle */}
<button onClick={() => setEditAllDay(v => !v)} style={{
width: '100%', padding: '12px 16px', borderRadius: 12,
background: editAllDay ? `${event.color}10` : 'rgba(255,255,255,0.025)',
border: `1px solid ${editAllDay ? event.color + '25' : 'rgba(255,255,255,0.06)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: 14, fontWeight: 500, color: editAllDay ? event.color : 'var(--text-secondary)' }}>Весь день</span>
<div style={{
width: 40, height: 22, borderRadius: 11,
background: editAllDay ? event.color : 'rgba(255,255,255,0.1)',
position: 'relative', transition: 'background 0.2s ease',
}}>
<div style={{
width: 18, height: 18, borderRadius: '50%', background: '#fff',
position: 'absolute', top: 2, left: editAllDay ? 20 : 2,
transition: 'left 0.2s ease', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}} />
</div>
</button>
{/* Time */}
{!editAllDay && (
<div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>Время</div>
<div style={{
display: 'flex', alignItems: 'center',
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 12, overflow: 'hidden',
}}>
<input type="time" value={editStartTime} onChange={e => 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',
}} />
<div style={{
padding: '0 10px', color: 'var(--text-tertiary)', fontSize: 12,
borderLeft: '1px solid rgba(255,255,255,0.06)',
borderRight: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(255,255,255,0.02)',
height: '100%', display: 'flex', alignItems: 'center',
}}>до</div>
<input type="time" value={editEndTime} onChange={e => 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',
}} />
</div>
</div>
)}
{saveError && (
<div style={{ color: '#f87171', fontSize: 13, padding: '8px 12px', borderRadius: 10, background: 'rgba(239,68,68,0.08)' }}>{saveError}</div>
)}
{/* Save / Cancel */}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button onClick={handleSave} disabled={saving} style={{
flex: 1, padding: '13px', borderRadius: 14,
background: `linear-gradient(135deg, ${event.color}45, ${event.color}30)`,
border: `1px solid ${event.color}35`,
color: '#c7d2fe', fontSize: 14, fontWeight: 700,
}}>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
<button onClick={() => { setEditing(false); setEditTitle(event.title) }} style={{
padding: '13px 20px', borderRadius: 14,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 14, fontWeight: 600,
}}>
Отмена
</button>
</div>
</>
) : (
<>
{/* View mode */}
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<CalendarDays size={18} color={event.color} />
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>
{formatFullDate(event.start)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3 }}>
{event.allDay ? 'Весь день' : `${formatTime(event.start)}${formatTime(event.end)}`}
</div>
</div>
</div>
{event.location && (
<div style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<MapPin size={18} color="#fb923c" />
<div style={{ fontSize: 14, color: 'var(--text-primary)', fontWeight: 500 }}>{event.location}</div>
</div>
)}
{event.description && (
<div style={{
padding: '14px 16px', borderRadius: 14,
background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<AlignLeft size={14} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>Описание</span>
</div>
<div style={{ fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>{event.description}</div>
</div>
)}
{/* Delete */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: 14, marginTop: 4 }}>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '12px', borderRadius: 14, width: '100%',
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.15)',
color: '#f87171', fontSize: 13, fontWeight: 600,
}}>
<Trash2 size={15} /> Удалить
</button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleDelete} disabled={deleting} style={{
flex: 1, padding: '12px', borderRadius: 14,
background: deleting ? 'rgba(239,68,68,0.06)' : 'rgba(239,68,68,0.12)',
border: '1px solid rgba(239,68,68,0.25)',
color: '#f87171', fontSize: 13, fontWeight: 600,
}}>
{deleting ? 'Удаление...' : 'Да, удалить'}
</button>
<button onClick={() => setConfirmDelete(false)} style={{
flex: 1, padding: '12px', borderRadius: 14,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600,
}}>
Отмена
</button>
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
)
}
// ————— 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 (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(8px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
<div style={{
background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(40px)',
border: '1px solid rgba(255,255,255,0.08)', borderRadius: 24,
padding: '24px 28px', width: 400, maxWidth: '95vw',
boxShadow: '0 25px 60px rgba(0,0,0,0.5)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<span style={{ fontSize: 17, fontWeight: 700, color: 'var(--text-primary)', textTransform: 'capitalize' }}>{label}</span>
<button onClick={onClose} style={{ color: 'var(--text-secondary)', padding: 4 }}><X size={18} /></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{events.map(e => (
<button key={e.id} onClick={() => onSelect(e)} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', borderRadius: 14, width: '100%', textAlign: 'left',
background: `${e.color}0c`, border: `1px solid ${e.color}20`,
transition: 'all 0.2s ease',
}}>
<div style={{ width: 4, borderRadius: 2, background: e.color, alignSelf: 'stretch', minHeight: 36, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 3, display: 'flex', gap: 8 }}>
<span>{e.allDay ? 'Весь день' : `${formatTime(e.start)}${formatTime(e.end)}`}</span>
<span style={{ color: e.color, fontWeight: 500 }}>{e.ownerName}</span>
</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}
// ————— Main Calendar —————
export default function CalendarTab() {
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().getMonth())
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [addDate, setAddDate] = useState<string>('')
const [hiddenOwners, setHiddenOwners] = useState<Set<string>>(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 calendarOwners = useMemo(() => {
const map = new Map<string, { owner: string; ownerName: string; color: string; count: number }>()
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 (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '16px 24px 24px', position: 'relative' }}>
{/* Main calendar grid */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={prevMonth} style={{ width: 36, height: 36, borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronLeft size={16} />
</button>
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', minWidth: 180, textAlign: 'center' }}>
{MONTHS[month]} {year}
</span>
<button onClick={nextMonth} style={{ width: 36, height: 36, borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={16} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{calendarOwners.map(cal => {
const isHidden = hiddenOwners.has(cal.owner)
return (
<button key={cal.owner} onClick={() => toggleOwner(cal.owner)} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '7px 14px', borderRadius: 12,
background: isHidden ? 'rgba(255,255,255,0.02)' : `${cal.color}15`,
border: `1px solid ${isHidden ? 'rgba(255,255,255,0.06)' : cal.color + '30'}`,
color: isHidden ? 'var(--text-tertiary)' : cal.color,
fontSize: 12, fontWeight: 600, transition: 'all 0.25s ease',
opacity: isHidden ? 0.5 : 1,
}}>
{isHidden ? <EyeOff size={13} /> : <Eye size={13} />}
{cal.ownerName}
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 6, background: isHidden ? 'rgba(255,255,255,0.05)' : `${cal.color}20` }}>
{cal.count}
</span>
</button>
)
})}
<button onClick={() => setShowUpcoming(v => !v)} style={{
width: 36, height: 36, borderRadius: 12,
background: showUpcoming ? 'rgba(99,102,241,0.15)' : 'rgba(255,255,255,0.04)',
border: showUpcoming ? '1px solid rgba(129,140,248,0.25)' : '1px solid rgba(255,255,255,0.06)',
color: showUpcoming ? '#a5b4fc' : 'var(--text-secondary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.25s ease',
}}>
<List size={16} />
</button>
<button onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }} style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 18px', borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
border: '1px solid rgba(129,140,248,0.25)',
color: '#a5b4fc', fontSize: 13, fontWeight: 600,
}}>
<Plus size={15} /> Событие
</button>
</div>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
{WEEKDAYS.map((d, i) => (
<div key={d} style={{
textAlign: 'center', fontSize: 11, fontWeight: 600,
color: i >= 5 ? 'rgba(248,113,113,0.5)' : 'var(--text-secondary)',
textTransform: 'uppercase', padding: '6px 0', letterSpacing: '0.05em',
}}>{d}</div>
))}
</div>
{/* Calendar cells */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 3 }}>
{cells.map((day, idx) => {
if (!day) return <div key={idx} />
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 (
<div
key={idx}
onClick={() => 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',
}}
>
<span style={{
fontSize: 12, fontWeight: isToday ? 700 : 500,
color: isToday ? '#a5b4fc' : isWeekend ? 'rgba(248,113,113,0.6)' : 'var(--text-secondary)',
textAlign: 'right', paddingRight: 3, lineHeight: 1,
}}>{day}</span>
{dayEvents.slice(0, 3).map(e => (
<div
key={e.id}
onClick={ev => { 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 && (
<span style={{ fontSize: 9, color: `${e.color}cc`, fontWeight: 600, flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatTime(e.start)}
</span>
)}
<span style={{
fontSize: 11, fontWeight: 600, color: e.color,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{e.title}
</span>
</div>
))}
{dayEvents.length > 3 && (
<div style={{ fontSize: 10, color: 'var(--text-secondary)', textAlign: 'center', fontWeight: 500 }}>
+{dayEvents.length - 3}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Right panel — overlay */}
{showUpcoming && <div style={{ position: 'fixed', inset: 0, zIndex: 90 }} onClick={() => setShowUpcoming(false)} />}
<div style={{
position: 'absolute', top: 16, right: 24, bottom: 24,
width: 240, display: 'flex', flexDirection: 'column', gap: 10,
overflowY: 'auto',
background: 'rgba(18,18,35,0.95)', backdropFilter: 'blur(30px)', WebkitBackdropFilter: 'blur(30px)',
borderRadius: 22, padding: '20px 16px',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 16px 48px rgba(0,0,0,0.4)',
transform: showUpcoming ? 'translateX(0)' : 'translateX(calc(100% + 24px))',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 95, pointerEvents: showUpcoming ? 'auto' : 'none',
}}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>Ближайшие</div>
{upcoming.length === 0 && !loading && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '20px 0', textAlign: 'center' }}>Нет событий</div>
)}
{upcoming.map(e => {
const d = new Date(e.start)
return (
<div key={e.id} onClick={() => setSelectedEvent(e)} style={{
borderRadius: 16, padding: '14px',
background: `${e.color}0c`, border: `1px solid ${e.color}1a`,
cursor: 'pointer', transition: 'all 0.25s ease',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 34, height: 34, borderRadius: 10, background: `${e.color}1a`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
</div>
<div>
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
<div style={{ fontSize: 10, color: 'var(--text-secondary)' }}>
{e.allDay ? 'Весь день' : formatTime(e.start)}
</div>
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
<div style={{ fontSize: 10, color: e.color, marginTop: 6, fontWeight: 500 }}>{e.ownerName}</div>
</div>
)
})}
</div>
{/* Modals */}
{selectedEvent && (
<EventDetailModal event={selectedEvent} onClose={() => setSelectedEvent(null)} onDelete={deleteEvent} onUpdate={updateEvent} />
)}
{dayPopover && (
<DayEventsModal
day={dayPopover.day} month={month} year={year} events={dayPopover.events}
onClose={() => setDayPopover(null)}
onSelect={e => { setDayPopover(null); setSelectedEvent(e) }}
/>
)}
{showAddModal && (
<AddEventModal
defaultDate={addDate}
onClose={() => setShowAddModal(false)}
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
/>
)}
</div>
)
}