Files
mkd/components/office/CompanyNewsRegistry.tsx
2026-02-04 00:17:04 +05:00

409 lines
18 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};