318 lines
16 KiB
TypeScript
318 lines
16 KiB
TypeScript
'use client'
|
||
import { useState, useEffect } from 'react'
|
||
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin } 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 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 [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
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,
|
||
}
|
||
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)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={onClose}>
|
||
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 340, width: '100%' }} onClick={e => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>Новое событие</span>
|
||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }}><X size={18} /></button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<input
|
||
value={title}
|
||
onChange={e => setTitle(e.target.value)}
|
||
placeholder="Название события"
|
||
autoFocus
|
||
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={date}
|
||
onChange={e => setDate(e.target.value)}
|
||
style={{ padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }}
|
||
/>
|
||
{!allDay && (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} />
|
||
<input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} style={{ flex: 1, padding: '10px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', fontSize: 14, outline: 'none' }} />
|
||
</div>
|
||
)}
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
||
Весь день
|
||
</label>
|
||
{error && <div style={{ color: '#f87171', fontSize: 13 }}>{error}</div>}
|
||
<button
|
||
onClick={save}
|
||
disabled={saving}
|
||
style={{ padding: '11px', borderRadius: 12, background: saving ? 'rgba(99,102,241,0.3)' : 'rgba(99,102,241,0.5)', border: '1px solid rgba(99,102,241,0.5)', color: '#a5b4fc', fontSize: 14, fontWeight: 600, cursor: saving ? 'default' : 'pointer', touchAction: 'manipulation' }}
|
||
>
|
||
{saving ? 'Сохранение...' : 'Создать событие'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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>('')
|
||
|
||
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])
|
||
|
||
// Upcoming events (next 30 days)
|
||
const upcoming = events
|
||
.filter(e => new Date(e.start) >= new Date())
|
||
.slice(0, 6)
|
||
|
||
// Build calendar grid
|
||
const firstDay = new Date(year, month, 1)
|
||
const lastDay = new Date(year, month + 1, 0)
|
||
// Monday-based week: 0=Mon, 6=Sun
|
||
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 events.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()
|
||
|
||
return (
|
||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '12px 16px 16px', gap: 16 }}>
|
||
{/* Main calendar grid */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<button onClick={prevMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', minWidth: 160, textAlign: 'center' }}>
|
||
{MONTHS[month]} {year}
|
||
</span>
|
||
<button onClick={nextMonth} style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={() => { setAddDate(today.toISOString().split('T')[0]); setShowAddModal(true) }}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(99,102,241,0.2)', border: '1px solid rgba(99,102,241,0.4)', color: '#a5b4fc', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }}
|
||
>
|
||
<Plus size={15} />
|
||
Событие
|
||
</button>
|
||
</div>
|
||
|
||
{/* Weekday headers */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: 4 }}>
|
||
{WEEKDAYS.map(d => (
|
||
<div key={d} style={{ textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', padding: '4px 0' }}>{d}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Calendar cells */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', flex: 1, gap: 2 }}>
|
||
{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
|
||
return (
|
||
<div
|
||
key={idx}
|
||
onClick={() => {
|
||
if (dayEvents.length === 1) setSelectedEvent(dayEvents[0])
|
||
else if (dayEvents.length === 0) {
|
||
setAddDate(`${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`)
|
||
setShowAddModal(true)
|
||
}
|
||
}}
|
||
style={{
|
||
borderRadius: 8,
|
||
padding: '4px 3px',
|
||
background: isToday ? 'rgba(99,102,241,0.12)' : 'rgba(255,255,255,0.02)',
|
||
border: isToday ? '1px solid rgba(99,102,241,0.35)' : '1px solid transparent',
|
||
cursor: 'pointer',
|
||
minHeight: 52,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 2,
|
||
touchAction: 'manipulation',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 12, fontWeight: isToday ? 700 : 400, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', textAlign: 'right', paddingRight: 3 }}>{day}</span>
|
||
{dayEvents.slice(0, 2).map(e => (
|
||
<div
|
||
key={e.id}
|
||
onClick={ev => { ev.stopPropagation(); setSelectedEvent(e) }}
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
background: e.color + '33',
|
||
border: `1px solid ${e.color}55`,
|
||
color: e.color,
|
||
borderRadius: 4,
|
||
padding: '1px 4px',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{e.title}
|
||
</div>
|
||
))}
|
||
{dayEvents.length > 2 && (
|
||
<div style={{ fontSize: 9, color: 'var(--text-secondary)', textAlign: 'center' }}>+{dayEvents.length - 2}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right panel: upcoming events */}
|
||
<div style={{ width: 200, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto' }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Ближайшие</div>
|
||
{upcoming.length === 0 && !loading && (
|
||
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Нет событий</div>
|
||
)}
|
||
{upcoming.map(e => {
|
||
const d = new Date(e.start)
|
||
return (
|
||
<div
|
||
key={e.id}
|
||
onClick={() => setSelectedEvent(e)}
|
||
style={{
|
||
borderRadius: 12,
|
||
padding: '10px 12px',
|
||
background: e.color + '18',
|
||
border: `1px solid ${e.color}33`,
|
||
cursor: 'pointer',
|
||
touchAction: 'manipulation',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||
<div style={{ width: 28, height: 28, borderRadius: 8, background: e.color + '33', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||
<span style={{ fontSize: 14, fontWeight: 700, color: e.color }}>{d.getDate()}</span>
|
||
</div>
|
||
<div style={{ fontSize: 10, color: e.color, fontWeight: 600 }}>{MONTHS[d.getMonth()].slice(0, 3)}</div>
|
||
</div>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.title}</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
|
||
{e.allDay ? 'Весь день' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||
</div>
|
||
<div style={{ fontSize: 10, color: e.color, marginTop: 3, fontWeight: 500 }}>{e.ownerName}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Event detail modal */}
|
||
{selectedEvent && (
|
||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }} onClick={() => setSelectedEvent(null)}>
|
||
<div style={{ background: 'var(--bg)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 20, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.5)' }} onClick={e => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||
<div style={{ width: 4, height: 40, borderRadius: 2, background: selectedEvent.color, marginRight: 12, flexShrink: 0, marginTop: 2 }} />
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{selectedEvent.title}</div>
|
||
<div style={{ fontSize: 12, color: selectedEvent.color, fontWeight: 500, marginTop: 2 }}>{selectedEvent.ownerName}</div>
|
||
</div>
|
||
<button onClick={() => setSelectedEvent(null)} style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 4 }}><X size={18} /></button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||
<Clock size={14} />
|
||
{selectedEvent.allDay
|
||
? 'Весь день'
|
||
: `${new Date(selectedEvent.start).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} — ${new Date(selectedEvent.end).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`
|
||
}
|
||
</div>
|
||
{selectedEvent.location && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||
<MapPin size={14} /> {selectedEvent.location}
|
||
</div>
|
||
)}
|
||
{selectedEvent.description && (
|
||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 4, lineHeight: 1.5 }}>{selectedEvent.description}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Add event modal */}
|
||
{showAddModal && (
|
||
<AddEventModal
|
||
defaultDate={addDate}
|
||
onClose={() => setShowAddModal(false)}
|
||
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|