'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { ChevronLeft, ChevronRight, Calendar, LayoutGrid, List, RefreshCw, Filter, Clock, CheckCircle2, XCircle, FileText, AlertCircle } from 'lucide-react'; // ── Константы ───────────────────────────────────────────────────────────────── const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь', 'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']; const STATUS_META = { draft: { label: 'Черновик', color: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30', dot: 'bg-zinc-400' }, scheduled: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' }, published: { label: 'Опубликован', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', dot: 'bg-emerald-400' }, failed: { label: 'Ошибка', color: 'bg-red-500/15 text-red-400 border-red-500/30', dot: 'bg-red-400' }, pending: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' }, }; const SOURCE_LABEL = { user_post: 'Пост', scheduled_post: 'Авто' }; // ── Утилиты ─────────────────────────────────────────────────────────────────── function startOfWeek(date) { const d = new Date(date); const day = d.getDay(); // 0=вс const diff = day === 0 ? -6 : 1 - day; // делаем пн первым d.setDate(d.getDate() + diff); d.setHours(0, 0, 0, 0); return d; } function addDays(date, n) { const d = new Date(date); d.setDate(d.getDate() + n); return d; } function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } function formatTime(iso) { if (!iso) return ''; const d = new Date(iso); return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }); } function formatDate(iso) { if (!iso) return ''; const d = new Date(iso); return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' }); } function isoDay(date) { return date.toISOString().slice(0, 10); } // ── EventCard ──────────────────────────────────────────────────────────────── function EventCard({ event, compact = false, draggable = false, onDragStart }) { const meta = STATUS_META[event.status] || STATUS_META.draft; return (
onDragStart(e, event) : undefined} className={` group rounded-md border px-2 py-1 text-xs cursor-default select-none transition-opacity hover:opacity-90 ${meta.color} ${draggable && event.editable ? 'cursor-grab active:cursor-grabbing' : ''} ${compact ? 'truncate' : ''} `} title={event.title || event.preview || ''} >
{event.scheduled_at && ( {formatTime(event.scheduled_at)} )} {event.title || event.preview?.slice(0, 60) || '—'} {!compact && ( {SOURCE_LABEL[event.source] || ''} )}
); } // ── DayCell ─────────────────────────────────────────────────────────────────── function DayCell({ date, events, isToday, isCurrentMonth, onDrop, onDragOver, onDragStart }) { const MAX_VISIBLE = 3; const visible = events.slice(0, MAX_VISIBLE); const hidden = events.length - MAX_VISIBLE; return (
onDrop(e, date)} >
{date.getDate()}
{visible.map(ev => ( ))} {hidden > 0 && (
+{hidden} ещё
)}
); } // ── MonthGrid ───────────────────────────────────────────────────────────────── function MonthGrid({ year, month, eventsByDay, onDrop, onDragOver, onDragStart }) { const today = new Date(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); // Заполняем сетку: пн первый день недели const startPad = ((firstDay.getDay() + 6) % 7); // смещение от пн const totalCells = Math.ceil((startPad + lastDay.getDate()) / 7) * 7; const cells = []; for (let i = 0; i < totalCells; i++) { const d = new Date(year, month, 1 - startPad + i); cells.push(d); } return (
{WEEKDAYS.map(w => (
{w}
))}
{cells.map((d, i) => ( ))}
); } // ── WeekGrid ────────────────────────────────────────────────────────────────── function WeekGrid({ weekStart, eventsByDay, onDrop, onDragOver, onDragStart }) { const today = new Date(); const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); return (
{days.map((d, i) => { const dayEvents = eventsByDay[isoDay(d)] || []; return (
onDrop(e, d)} >
{d.getDate()}
{WEEKDAYS[i]}
{dayEvents.map(ev => ( ))}
); })}
); } // ── ListView ────────────────────────────────────────────────────────────────── function ListView({ events }) { if (!events.length) { return (

Нет событий в выбранном периоде

); } // Группируем по дням const groups = {}; for (const ev of events) { const key = isoDay(new Date(ev.date)); if (!groups[key]) groups[key] = []; groups[key].push(ev); } return (
{Object.entries(groups).sort().map(([day, evs]) => { const d = new Date(day); const today = new Date(); const isToday = sameDay(d, today); return (
{isToday ? 'Сегодня' : d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' })}
{evs.map(ev => { const meta = STATUS_META[ev.status] || STATUS_META.draft; return (
{ev.title || ev.preview?.slice(0, 80) || '—'} {meta.label} {SOURCE_LABEL[ev.source]}
{ev.channel_name} {ev.scheduled_at && ( {formatTime(ev.scheduled_at)} )} {ev.error && ( {ev.error.slice(0, 60)} )}
); })}
); })}
); } // ── Legend ──────────────────────────────────────────────────────────────────── function Legend() { const items = [ { status: 'draft', Icon: FileText }, { status: 'scheduled', Icon: Clock }, { status: 'published', Icon: CheckCircle2 }, { status: 'failed', Icon: XCircle }, ]; return (
{items.map(({ status, Icon }) => { const m = STATUS_META[status]; return (
{m.label}
); })}
); } // ── Главный компонент ───────────────────────────────────────────────────────── export default function CalendarView({ channels }) { const today = new Date(); const [view, setView] = useState('month'); // month | week | list const [currentDate, setCurrentDate] = useState(today); const [channelFilter, setChannelFilter] = useState(''); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const dragRef = useRef(null); // перетаскиваемое событие // Вычисляем диапазон для запроса const getRange = useCallback(() => { if (view === 'week') { const ws = startOfWeek(currentDate); return { from: isoDay(ws), to: isoDay(addDays(ws, 6)), }; } // month и list — показываем текущий + соседние return { from: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)), to: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)), }; }, [view, currentDate]); const fetchEvents = useCallback(async () => { setLoading(true); setError(''); try { const range = getRange(); const qs = new URLSearchParams(range); if (channelFilter) qs.set('channel_id', channelFilter); const res = await fetch(`/api/calendar?${qs}`); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Ошибка загрузки'); setEvents(data.events || []); } catch (e) { setError(e.message); } finally { setLoading(false); } }, [getRange, channelFilter]); useEffect(() => { fetchEvents(); }, [fetchEvents]); // Группировка по дням для grid-видов const eventsByDay = {}; for (const ev of events) { const key = isoDay(new Date(ev.date)); if (!eventsByDay[key]) eventsByDay[key] = []; eventsByDay[key].push(ev); } // ── Навигация ────────────────────────────────────────────────────────────── function navigate(dir) { const d = new Date(currentDate); if (view === 'week') { d.setDate(d.getDate() + dir * 7); } else { d.setMonth(d.getMonth() + dir); } setCurrentDate(d); } function goToday() { setCurrentDate(new Date()); } function headerLabel() { if (view === 'week') { const ws = startOfWeek(currentDate); const we = addDays(ws, 6); return `${ws.getDate()} — ${we.getDate()} ${MONTHS[we.getMonth()]} ${we.getFullYear()}`; } return `${MONTHS[currentDate.getMonth()]} ${currentDate.getFullYear()}`; } // ── Drag & drop ──────────────────────────────────────────────────────────── function handleDragStart(e, event) { dragRef.current = event; e.dataTransfer.effectAllowed = 'move'; } function handleDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } async function handleDrop(e, targetDate) { e.preventDefault(); const ev = dragRef.current; dragRef.current = null; if (!ev) return; if (!ev.editable || ev.source !== 'user_post') return; // Берём время из оригинального scheduled_at, меняем только дату const orig = ev.scheduled_at ? new Date(ev.scheduled_at) : new Date(); const newDt = new Date(targetDate); newDt.setHours(orig.getHours(), orig.getMinutes(), 0, 0); // Оптимистичное обновление setEvents(prev => prev.map(e2 => e2.id === ev.id ? { ...e2, scheduled_at: newDt.toISOString(), date: newDt.toISOString() } : e2 )); try { const res = await fetch('/api/calendar', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: ev.source_id, scheduled_at: newDt.toISOString() }), }); if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Ошибка переноса'); } } catch (err) { alert(`Не удалось перенести: ${err.message}`); fetchEvents(); // откатываем } } // ── Рендер ──────────────────────────────────────────────────────────────── const viewBtns = [ { id: 'month', Icon: LayoutGrid, label: 'Месяц' }, { id: 'week', Icon: Calendar, label: 'Неделя' }, { id: 'list', Icon: List, label: 'Список' }, ]; return (
{/* ── Тулбар ── */}
{/* Навигация */}
{/* Заголовок периода */}

{headerLabel()}

{/* Фильтр канала */} {channels.length > 0 && (
)} {/* Переключатель вида */}
{viewBtns.map(({ id, Icon, label }) => ( ))}
{/* Обновить */}
{/* Легенда */}
{events.length > 0 && ( {events.length} событий )}
{/* Ошибка */} {error && (
{error}
)} {/* Скелетон загрузки */} {loading && events.length === 0 && (
Загружаю события…
)} {/* Контент */} {!loading || events.length > 0 ? ( <> {view === 'month' && ( )} {view === 'week' && ( )} {view === 'list' && } ) : null} {/* Подсказка про drag & drop */} {(view === 'month' || view === 'week') && (

Перетащи карточку пользовательского поста на другую дату, чтобы перенести его

)}
); }