'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 (
{/* ── Тулбар ── */}
{/* Навигация */}
navigate(-1)} className="btn-ghost p-2 rounded-lg">
Сегодня
navigate(1)} className="btn-ghost p-2 rounded-lg">
{/* Заголовок периода */}
{headerLabel()}
{/* Фильтр канала */}
{channels.length > 0 && (
setChannelFilter(e.target.value)}
>
Все каналы
{channels.map(ch => (
{ch.name}
))}
)}
{/* Переключатель вида */}
{viewBtns.map(({ id, Icon, label }) => (
setView(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${view === id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}
>
{label}
))}
{/* Обновить */}
{/* Легенда */}
{events.length > 0 && (
{events.length} событий
)}
{/* Ошибка */}
{error && (
)}
{/* Скелетон загрузки */}
{loading && events.length === 0 && (
Загружаю события…
)}
{/* Контент */}
{!loading || events.length > 0 ? (
<>
{view === 'month' && (
)}
{view === 'week' && (
)}
{view === 'list' &&
}
>
) : null}
{/* Подсказка про drag & drop */}
{(view === 'month' || view === 'week') && (
Перетащи карточку пользовательского поста на другую дату, чтобы перенести его
)}
);
}