409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { CompanyNews, Department } from '../../types';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
import { readCache, saveCache } from '../../hooks/useCachedFetch';
|
|||
|
|
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
|
|||
|
|
import { Newspaper, Plus, Edit, Trash2, Send, X, Clock, CheckCircle2, FileEdit } from 'lucide-react';
|
|||
|
|
|
|||
|
|
type StatusFilter = 'all' | 'draft' | 'pending' | 'published';
|
|||
|
|
|
|||
|
|
const DEPT_LABELS: Record<Department, string> = {
|
|||
|
|
production: 'Производство',
|
|||
|
|
pr: 'PR',
|
|||
|
|
finance: 'Финансы',
|
|||
|
|
development: 'Развитие',
|
|||
|
|
legal: 'Юр. отдел',
|
|||
|
|
hr: 'Кадры',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const CACHE_KEY = 'mkd_office_news_cache';
|
|||
|
|
|
|||
|
|
export const CompanyNewsRegistry: React.FC = () => {
|
|||
|
|
const cached = readCache<CompanyNews[]>(CACHE_KEY, []);
|
|||
|
|
const [items, setItems] = useState<CompanyNews[]>(cached);
|
|||
|
|
const [loading, setLoading] = useState(cached.length === 0);
|
|||
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
|||
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|||
|
|
const [editingItem, setEditingItem] = useState<CompanyNews | null>(null);
|
|||
|
|
const [submitLoading, setSubmitLoading] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const fetchList = async (showSpinner = true) => {
|
|||
|
|
if (showSpinner && !(statusFilter === 'all' && cached.length > 0)) setLoading(true);
|
|||
|
|
try {
|
|||
|
|
const params = statusFilter !== 'all' ? { status: statusFilter, limit: 100 } : { limit: 100 };
|
|||
|
|
const list = await backendApi.getCompanyNews(params);
|
|||
|
|
const arr = Array.isArray(list) ? list : [];
|
|||
|
|
setItems(arr);
|
|||
|
|
if (statusFilter === 'all') saveCache(CACHE_KEY, arr);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[CompanyNewsRegistry] fetch:', err);
|
|||
|
|
setItems([]);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchList();
|
|||
|
|
}, [statusFilter]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const onRefresh = () => fetchList(false);
|
|||
|
|
window.addEventListener(REFRESH_EVENTS.news, onRefresh);
|
|||
|
|
return () => window.removeEventListener(REFRESH_EVENTS.news, onRefresh);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const interval = setInterval(() => fetchList(false), 10 * 1000);
|
|||
|
|
return () => clearInterval(interval);
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handleCreate = () => {
|
|||
|
|
setEditingItem(null);
|
|||
|
|
setModalOpen(true);
|
|||
|
|
setError(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleEdit = (item: CompanyNews) => {
|
|||
|
|
setEditingItem(item);
|
|||
|
|
setModalOpen(true);
|
|||
|
|
setError(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handlePublish = async (item: CompanyNews) => {
|
|||
|
|
if (item.status === 'published') return;
|
|||
|
|
setSubmitLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.updateCompanyNews(item.id, { ...item, status: 'published' });
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-news-changed'));
|
|||
|
|
await fetchList();
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err?.message || 'Не удалось опубликовать');
|
|||
|
|
} finally {
|
|||
|
|
setSubmitLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDelete = async (item: CompanyNews) => {
|
|||
|
|
if (!confirm(`Удалить новость «${item.title}»?`)) return;
|
|||
|
|
setSubmitLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
await backendApi.deleteCompanyNews(item.id);
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-news-changed'));
|
|||
|
|
await fetchList();
|
|||
|
|
setModalOpen(false);
|
|||
|
|
setEditingItem(null);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err?.message || 'Не удалось удалить');
|
|||
|
|
} finally {
|
|||
|
|
setSubmitLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleModalSuccess = () => {
|
|||
|
|
setModalOpen(false);
|
|||
|
|
setEditingItem(null);
|
|||
|
|
fetchList();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const filtered = items;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
<div className="flex flex-wrap justify-between items-center gap-4 px-1">
|
|||
|
|
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Реестр новостей компании</h3>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="flex p-1 bg-slate-100 rounded-xl gap-0.5">
|
|||
|
|
{(['all', 'draft', 'pending', 'published'] as StatusFilter[]).map((s) => (
|
|||
|
|
<button
|
|||
|
|
key={s}
|
|||
|
|
onClick={() => setStatusFilter(s)}
|
|||
|
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${
|
|||
|
|
statusFilter === s ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{s === 'all' ? 'Все' : s === 'draft' ? 'Черновики' : s === 'pending' ? 'На согласовании' : 'Опубликовано'}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={handleCreate}
|
|||
|
|
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" /> Создать новость
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{error && (
|
|||
|
|
<div className="px-4 py-2 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700 font-medium">
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="text-center py-20 text-slate-400">
|
|||
|
|
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
|
|||
|
|
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
|
|||
|
|
</div>
|
|||
|
|
) : filtered.length === 0 ? (
|
|||
|
|
<div className="text-center py-20 text-slate-300 bg-white rounded-2xl border border-slate-200">
|
|||
|
|
<Newspaper className="w-12 h-12 mx-auto mb-3 text-slate-200" />
|
|||
|
|
<p className="text-[10px] font-bold uppercase tracking-widest">Нет новостей</p>
|
|||
|
|
<button onClick={handleCreate} className="mt-4 text-primary-600 text-sm font-bold hover:underline">
|
|||
|
|
Создать новость
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
|||
|
|
<ul className="divide-y divide-slate-100">
|
|||
|
|
{filtered.map((n) => (
|
|||
|
|
<li key={n.id} className="p-4 hover:bg-slate-50/50 transition-colors flex flex-wrap items-center justify-between gap-3">
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<p className="font-bold text-slate-800 truncate">{n.title}</p>
|
|||
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
|||
|
|
<span>
|
|||
|
|
{n.publishedAt
|
|||
|
|
? new Date(n.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })
|
|||
|
|
: new Date(n.createdAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}
|
|||
|
|
</span>
|
|||
|
|
{n.createdByName && <span>{n.createdByName}</span>}
|
|||
|
|
<span
|
|||
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full font-bold ${
|
|||
|
|
n.status === 'published'
|
|||
|
|
? 'bg-emerald-100 text-emerald-700'
|
|||
|
|
: n.status === 'pending'
|
|||
|
|
? 'bg-amber-100 text-amber-700'
|
|||
|
|
: 'bg-slate-100 text-slate-600'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{n.status === 'published' && <CheckCircle2 className="w-3 h-3" />}
|
|||
|
|
{n.status === 'pending' && <Clock className="w-3 h-3" />}
|
|||
|
|
{n.status === 'draft' && <FileEdit className="w-3 h-3" />}
|
|||
|
|
{n.status === 'published' ? 'Опубликовано' : n.status === 'pending' ? 'На согласовании' : 'Черновик'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
{n.body && <p className="text-sm text-slate-600 mt-1 line-clamp-2">{n.body}</p>}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleEdit(n)}
|
|||
|
|
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
|||
|
|
title="Редактировать"
|
|||
|
|
>
|
|||
|
|
<Edit className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
{n.status !== 'published' && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => handlePublish(n)}
|
|||
|
|
disabled={submitLoading}
|
|||
|
|
className="p-2 text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors disabled:opacity-50"
|
|||
|
|
title="Опубликовать"
|
|||
|
|
>
|
|||
|
|
<Send className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleDelete(n)}
|
|||
|
|
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|||
|
|
title="Удалить"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{modalOpen && (
|
|||
|
|
<CompanyNewsFormModal
|
|||
|
|
item={editingItem}
|
|||
|
|
onClose={() => {
|
|||
|
|
setModalOpen(false);
|
|||
|
|
setEditingItem(null);
|
|||
|
|
setError(null);
|
|||
|
|
}}
|
|||
|
|
onSuccess={handleModalSuccess}
|
|||
|
|
onError={(msg) => setError(msg)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ========== Form Modal (create / edit) ==========
|
|||
|
|
interface CompanyNewsFormModalProps {
|
|||
|
|
item: CompanyNews | null;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSuccess: () => void;
|
|||
|
|
onError: (msg: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const CompanyNewsFormModal: React.FC<CompanyNewsFormModalProps> = ({ item, onClose, onSuccess, onError }) => {
|
|||
|
|
const [title, setTitle] = useState(item?.title ?? '');
|
|||
|
|
const [body, setBody] = useState(item?.body ?? '');
|
|||
|
|
const [status, setStatus] = useState<CompanyNews['status']>(item?.status ?? 'draft');
|
|||
|
|
const [notifyDepartments, setNotifyDepartments] = useState<Department[]>(item?.notifyDepartments ?? []);
|
|||
|
|
const [notifyEmployeeIds, setNotifyEmployeeIds] = useState<string[]>(item?.notifyEmployeeIds ?? []);
|
|||
|
|
const [employeesList, setEmployeesList] = useState<Array<{ id: string; name: string }>>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (item) {
|
|||
|
|
setTitle(item.title);
|
|||
|
|
setBody(item.body ?? '');
|
|||
|
|
setStatus(item.status);
|
|||
|
|
setNotifyDepartments(Array.isArray(item.notifyDepartments) ? item.notifyDepartments : []);
|
|||
|
|
setNotifyEmployeeIds(Array.isArray(item.notifyEmployeeIds) ? item.notifyEmployeeIds : []);
|
|||
|
|
} else {
|
|||
|
|
setTitle('');
|
|||
|
|
setBody('');
|
|||
|
|
setStatus('draft');
|
|||
|
|
setNotifyDepartments([]);
|
|||
|
|
setNotifyEmployeeIds([]);
|
|||
|
|
}
|
|||
|
|
}, [item]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
backendApi.getEmployeesList().then((list) => setEmployeesList(Array.isArray(list) ? list : [])).catch(() => setEmployeesList([]));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const toggleDepartment = (d: Department) => {
|
|||
|
|
setNotifyDepartments((prev) => (prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d]));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const toggleEmployee = (id: string) => {
|
|||
|
|
setNotifyEmployeeIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!title.trim()) {
|
|||
|
|
onError('Укажите заголовок');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setLoading(true);
|
|||
|
|
onError('');
|
|||
|
|
try {
|
|||
|
|
if (item) {
|
|||
|
|
await backendApi.updateCompanyNews(item.id, {
|
|||
|
|
title: title.trim(),
|
|||
|
|
body: body.trim() || null,
|
|||
|
|
status,
|
|||
|
|
notifyDepartments: notifyDepartments.length ? notifyDepartments : undefined,
|
|||
|
|
notifyEmployeeIds: notifyEmployeeIds.length ? notifyEmployeeIds : undefined,
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
await backendApi.createCompanyNews({
|
|||
|
|
title: title.trim(),
|
|||
|
|
body: body.trim() || null,
|
|||
|
|
status,
|
|||
|
|
notifyDepartments: notifyDepartments.length ? notifyDepartments : undefined,
|
|||
|
|
notifyEmployeeIds: notifyEmployeeIds.length ? notifyEmployeeIds : undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
window.dispatchEvent(new CustomEvent('mkd-news-changed'));
|
|||
|
|
onSuccess();
|
|||
|
|
} catch (err: any) {
|
|||
|
|
onError(err?.message || 'Не удалось сохранить');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={(e) => e.stopPropagation()}>
|
|||
|
|
<div className="p-6 border-b border-slate-200 flex justify-between items-center sticky top-0 bg-white z-10 rounded-t-2xl">
|
|||
|
|
<h3 className="text-xl font-black text-slate-900">{item ? 'Редактировать новость' : 'Создать новость'}</h3>
|
|||
|
|
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600 rounded-xl hover:bg-slate-100">
|
|||
|
|
<X className="w-5 h-5" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Заголовок *</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={title}
|
|||
|
|
onChange={(e) => setTitle(e.target.value)}
|
|||
|
|
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|||
|
|
placeholder="Заголовок новости"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Текст</label>
|
|||
|
|
<textarea
|
|||
|
|
value={body}
|
|||
|
|
onChange={(e) => setBody(e.target.value)}
|
|||
|
|
rows={4}
|
|||
|
|
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-y"
|
|||
|
|
placeholder="Текст новости"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{item && (
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-1">Статус</label>
|
|||
|
|
<select
|
|||
|
|
value={status}
|
|||
|
|
onChange={(e) => setStatus(e.target.value as CompanyNews['status'])}
|
|||
|
|
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|||
|
|
>
|
|||
|
|
<option value="draft">Черновик</option>
|
|||
|
|
<option value="pending">На согласовании</option>
|
|||
|
|
<option value="published">Опубликовано</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">Уведомить по отделам</label>
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{(['production', 'pr', 'finance', 'development', 'legal', 'hr'] as Department[]).map((d) => (
|
|||
|
|
<button
|
|||
|
|
key={d}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => toggleDepartment(d)}
|
|||
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
|||
|
|
notifyDepartments.includes(d) ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{DEPT_LABELS[d]}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-bold text-slate-600 mb-2">Уведомить лично (сотрудники)</label>
|
|||
|
|
<div className="max-h-32 overflow-y-auto border border-slate-200 rounded-xl p-2 space-y-1">
|
|||
|
|
{employeesList.map((emp) => (
|
|||
|
|
<label key={emp.id} className="flex items-center gap-2 py-1 px-2 rounded-lg hover:bg-slate-50 cursor-pointer">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={notifyEmployeeIds.includes(emp.id)}
|
|||
|
|
onChange={() => toggleEmployee(emp.id)}
|
|||
|
|
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
|
|||
|
|
/>
|
|||
|
|
<span className="text-sm text-slate-700">{emp.name}</span>
|
|||
|
|
</label>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-3 pt-2">
|
|||
|
|
<button type="button" onClick={onClose} className="flex-1 py-3 border border-slate-200 rounded-xl font-bold text-slate-600 hover:bg-slate-50">
|
|||
|
|
Отмена
|
|||
|
|
</button>
|
|||
|
|
<button type="submit" disabled={loading} className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 disabled:opacity-50">
|
|||
|
|
{loading ? 'Сохранение...' : item ? 'Сохранить' : 'Создать'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|