876 lines
40 KiB
TypeScript
876 lines
40 KiB
TypeScript
'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>
|
||
)
|
||
}
|