feat: P1 Calendar — CalendarView (month/week/list, drag&drop, channel filter)
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
'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 (
|
||||
<div
|
||||
draggable={draggable && event.editable}
|
||||
onDragStart={draggable ? (e) => 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 || ''}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className={`shrink-0 w-1.5 h-1.5 rounded-full ${meta.dot}`} />
|
||||
{event.scheduled_at && (
|
||||
<span className="shrink-0 opacity-70">{formatTime(event.scheduled_at)}</span>
|
||||
)}
|
||||
<span className="truncate font-medium">
|
||||
{event.title || event.preview?.slice(0, 60) || '—'}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="shrink-0 ml-auto opacity-50 text-[10px]">
|
||||
{SOURCE_LABEL[event.source] || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
className={`
|
||||
min-h-[90px] p-1.5 rounded-lg border transition-colors
|
||||
${isCurrentMonth ? 'bg-surface border-border' : 'bg-transparent border-transparent opacity-40'}
|
||||
${isToday ? 'ring-1 ring-accent/60' : ''}
|
||||
`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={(e) => onDrop(e, date)}
|
||||
>
|
||||
<div className={`text-xs font-semibold mb-1 w-6 h-6 flex items-center justify-center rounded-full
|
||||
${isToday ? 'bg-accent text-white' : 'text-text-soft'}`}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{visible.map(ev => (
|
||||
<EventCard key={ev.id} event={ev} compact draggable onDragStart={onDragStart} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<div className="text-[10px] text-text-mute pl-1">+{hidden} ещё</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div>
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{WEEKDAYS.map(w => (
|
||||
<div key={w} className="text-center text-xs font-medium text-text-mute py-1">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((d, i) => (
|
||||
<DayCell
|
||||
key={i}
|
||||
date={d}
|
||||
events={eventsByDay[isoDay(d)] || []}
|
||||
isToday={sameDay(d, today)}
|
||||
isCurrentMonth={d.getMonth() === month}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WeekGrid ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function WeekGrid({ weekStart, eventsByDay, onDrop, onDragOver, onDragStart }) {
|
||||
const today = new Date();
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{days.map((d, i) => {
|
||||
const dayEvents = eventsByDay[isoDay(d)] || [];
|
||||
return (
|
||||
<div key={i}
|
||||
className={`min-h-[200px] rounded-xl border p-2
|
||||
${sameDay(d, today) ? 'ring-1 ring-accent/60 border-accent/30' : 'border-border'}
|
||||
bg-surface`}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={(e) => onDrop(e, d)}
|
||||
>
|
||||
<div className={`text-xs font-semibold mb-2 text-center w-7 h-7 mx-auto
|
||||
flex items-center justify-center rounded-full
|
||||
${sameDay(d, today) ? 'bg-accent text-white' : 'text-text-soft'}`}>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
<div className="text-[10px] text-text-mute text-center mb-2">
|
||||
{WEEKDAYS[i]}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{dayEvents.map(ev => (
|
||||
<EventCard key={ev.id} event={ev} draggable onDragStart={onDragStart} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ListView ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function ListView({ events }) {
|
||||
if (!events.length) {
|
||||
return (
|
||||
<div className="card p-12 text-center text-text-mute">
|
||||
<Calendar className="w-8 h-8 mx-auto mb-3 opacity-30" />
|
||||
<p>Нет событий в выбранном периоде</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Группируем по дням
|
||||
const groups = {};
|
||||
for (const ev of events) {
|
||||
const key = isoDay(new Date(ev.date));
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.entries(groups).sort().map(([day, evs]) => {
|
||||
const d = new Date(day);
|
||||
const today = new Date();
|
||||
const isToday = sameDay(d, today);
|
||||
return (
|
||||
<div key={day}>
|
||||
<div className={`text-sm font-semibold mb-2 ${isToday ? 'text-accent' : 'text-text-soft'}`}>
|
||||
{isToday ? 'Сегодня' : d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{evs.map(ev => {
|
||||
const meta = STATUS_META[ev.status] || STATUS_META.draft;
|
||||
return (
|
||||
<div key={ev.id} className="card p-3 flex items-start gap-3">
|
||||
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${meta.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{ev.title || ev.preview?.slice(0, 80) || '—'}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${meta.color}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-xs text-text-mute">
|
||||
{SOURCE_LABEL[ev.source]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-text-mute">
|
||||
<span>{ev.channel_name}</span>
|
||||
{ev.scheduled_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(ev.scheduled_at)}
|
||||
</span>
|
||||
)}
|
||||
{ev.error && (
|
||||
<span className="text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{ev.error.slice(0, 60)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legend ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Legend() {
|
||||
const items = [
|
||||
{ status: 'draft', Icon: FileText },
|
||||
{ status: 'scheduled', Icon: Clock },
|
||||
{ status: 'published', Icon: CheckCircle2 },
|
||||
{ status: 'failed', Icon: XCircle },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{items.map(({ status, Icon }) => {
|
||||
const m = STATUS_META[status];
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-1.5 text-xs text-text-soft">
|
||||
<span className={`w-2 h-2 rounded-full ${m.dot}`} />
|
||||
{m.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Главный компонент ─────────────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Тулбар ── */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
{/* Навигация */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => navigate(-1)} className="btn-ghost p-2 rounded-lg">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={goToday} className="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium">
|
||||
Сегодня
|
||||
</button>
|
||||
<button onClick={() => navigate(1)} className="btn-ghost p-2 rounded-lg">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Заголовок периода */}
|
||||
<h2 className="text-lg font-semibold flex-1 min-w-0">{headerLabel()}</h2>
|
||||
|
||||
{/* Фильтр канала */}
|
||||
{channels.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-text-mute" />
|
||||
<select
|
||||
className="input py-1.5 text-sm w-40"
|
||||
value={channelFilter}
|
||||
onChange={e => setChannelFilter(e.target.value)}
|
||||
>
|
||||
<option value="">Все каналы</option>
|
||||
{channels.map(ch => (
|
||||
<option key={ch.id} value={ch.id}>{ch.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Переключатель вида */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border">
|
||||
{viewBtns.map(({ id, Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => 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'}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Обновить */}
|
||||
<button onClick={fetchEvents} className="btn-ghost p-2 rounded-lg" title="Обновить">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Легенда */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Legend />
|
||||
{events.length > 0 && (
|
||||
<span className="text-xs text-text-mute">{events.length} событий</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<div className="card p-3 text-sm text-red-400 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Скелетон загрузки */}
|
||||
{loading && events.length === 0 && (
|
||||
<div className="card p-8 text-center text-text-mute animate-pulse">
|
||||
Загружаю события…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Контент */}
|
||||
{!loading || events.length > 0 ? (
|
||||
<>
|
||||
{view === 'month' && (
|
||||
<MonthGrid
|
||||
year={currentDate.getFullYear()}
|
||||
month={currentDate.getMonth()}
|
||||
eventsByDay={eventsByDay}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<WeekGrid
|
||||
weekStart={startOfWeek(currentDate)}
|
||||
eventsByDay={eventsByDay}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
)}
|
||||
{view === 'list' && <ListView events={events} />}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Подсказка про drag & drop */}
|
||||
{(view === 'month' || view === 'week') && (
|
||||
<p className="text-xs text-text-mute text-center">
|
||||
Перетащи карточку пользовательского поста на другую дату, чтобы перенести его
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user