forked from admin/zeropost-tool
563 lines
21 KiB
JavaScript
563 lines
21 KiB
JavaScript
'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>
|
|
);
|
|
}
|