Files
smart-home-tablet/components/CalendarTab.tsx
Cosmo 4874466985
All checks were successful
Deploy / deploy (push) Successful in 4m37s
feat: add calendar event deletion with confirmation
2026-04-22 18:28:13 +00:00

365 lines
19 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 } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Clock, MapPin, Trash2 } 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 [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = 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])
const deleteEvent = async (event: CalendarEvent) => {
setDeleting(true)
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)
setConfirmDelete(false)
} catch (e: any) {
alert(e.message || 'Ошибка удаления')
} finally {
setDeleting(false)
}
}
// 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); setConfirmDelete(false) }}>
<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>
)}
{/* Delete button */}
<div style={{ marginTop: 8, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12 }}>
{!confirmDelete ? (
<button
onClick={() => setConfirmDelete(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px', borderRadius: 10, background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation', width: '100%', justifyContent: 'center' }}
>
<Trash2 size={14} />
Удалить событие
</button>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => deleteEvent(selectedEvent)}
disabled={deleting}
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: deleting ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.25)', border: '1px solid rgba(239,68,68,0.5)', color: '#f87171', fontSize: 13, fontWeight: 600, cursor: deleting ? 'default' : 'pointer', touchAction: 'manipulation' }}
>
{deleting ? 'Удаление...' : 'Да, удалить'}
</button>
<button
onClick={() => setConfirmDelete(false)}
style={{ flex: 1, padding: '8px 14px', borderRadius: 10, background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', touchAction: 'manipulation' }}
>
Отмена
</button>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Add event modal */}
{showAddModal && (
<AddEventModal
defaultDate={addDate}
onClose={() => setShowAddModal(false)}
onSaved={(newEvent) => { setEvents(prev => [...prev, newEvent]); setShowAddModal(false) }}
/>
)}
</div>
)
}