poradniki
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Panel administratora: moderacja propozycji artykułów użytkowników ("Poradniki - prośby").
|
||||
* Filtr po statusie, podgląd treści i załączników, akcje: akceptuj / odrzuć / usuń.
|
||||
* Komponent `Icon` przekazywany jest propem, aby uniknąć cyklicznego importu z App.tsx.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../auth';
|
||||
import { SafeHtml } from '../RichText';
|
||||
import { GUIDE_TOPICS, articlePath } from '../routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type IconComponent = (props: { name: string }) => JSX.Element | null;
|
||||
|
||||
type ArticleStatus = 'PENDING' | 'PUBLISHED' | 'REJECTED';
|
||||
|
||||
type AdminArticle = {
|
||||
id: number;
|
||||
topicSlug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
attachments: string[];
|
||||
authorName: string;
|
||||
authorNick: string | null;
|
||||
authorEmail: string;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
readMinutes: number;
|
||||
status: ArticleStatus;
|
||||
rejectionReason: string | null;
|
||||
reviewedByEmail: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_FILTERS: { key: string; label: string }[] = [
|
||||
{ key: 'PENDING', label: 'Oczekujące' },
|
||||
{ key: 'PUBLISHED', label: 'Opublikowane' },
|
||||
{ key: 'REJECTED', label: 'Odrzucone' },
|
||||
{ key: 'ALL', label: 'Wszystkie' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ArticleStatus, string> = {
|
||||
PENDING: 'Oczekuje',
|
||||
PUBLISHED: 'Opublikowany',
|
||||
REJECTED: 'Odrzucony',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<ArticleStatus, string> = {
|
||||
PENDING: 'pending',
|
||||
PUBLISHED: 'approved',
|
||||
REJECTED: 'rejected',
|
||||
};
|
||||
|
||||
function topicTitle(slug: string): string {
|
||||
return GUIDE_TOPICS.find((topic) => topic.slug === slug)?.title ?? slug;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
type NotifyChannel = 'IN_APP' | 'EMAIL';
|
||||
|
||||
export function AdminArticleRequestsView({ Icon, onModerated }: { Icon: IconComponent; onModerated?: () => void }) {
|
||||
const [statusFilter, setStatusFilter] = useState('PENDING');
|
||||
const [items, setItems] = useState<AdminArticle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
// Otwarty panel decyzji: dla którego artykułu i jaki typ (akceptacja / odrzucenie).
|
||||
const [decision, setDecision] = useState<{ id: number; type: 'approve' | 'reject' } | null>(null);
|
||||
const [channel, setChannel] = useState<NotifyChannel>('IN_APP');
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiFetch<AdminArticle[]>(`/admin/articles?status=${statusFilter}`);
|
||||
setItems(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openDecision = (id: number, type: 'approve' | 'reject') => {
|
||||
setDecision({ id, type });
|
||||
setChannel('IN_APP');
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const runAction = async (id: number, action: () => Promise<unknown>) => {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await action();
|
||||
await load();
|
||||
onModerated?.();
|
||||
} catch (err) {
|
||||
window.alert(err instanceof Error ? err.message : 'Nie udało się wykonać akcji.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDecision = (id: number, type: 'approve' | 'reject') =>
|
||||
runAction(id, async () => {
|
||||
const path = type === 'approve' ? 'approve' : 'reject';
|
||||
const payload = type === 'approve' ? { channel } : { channel, reason: rejectReason.trim() };
|
||||
await apiFetch(`/admin/articles/${id}/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
||||
setDecision(null);
|
||||
setRejectReason('');
|
||||
});
|
||||
|
||||
const remove = (id: number) => {
|
||||
if (!window.confirm('Usunąć tę propozycję na stałe? Tej operacji nie można cofnąć.')) {
|
||||
return;
|
||||
}
|
||||
void runAction(id, () => apiFetch(`/admin/articles/${id}`, { method: 'DELETE' }));
|
||||
};
|
||||
|
||||
const channelPicker = (
|
||||
<div className="admin-article-channel">
|
||||
<span>Powiadom autora przez:</span>
|
||||
<label className={channel === 'IN_APP' ? 'active' : ''}>
|
||||
<input type="radio" name="channel" checked={channel === 'IN_APP'} onChange={() => setChannel('IN_APP')} />
|
||||
<Icon name="bell" /> Wiadomość w aplikacji
|
||||
</label>
|
||||
<label className={channel === 'EMAIL' ? 'active' : ''}>
|
||||
<input type="radio" name="channel" checked={channel === 'EMAIL'} onChange={() => setChannel('EMAIL')} />
|
||||
<Icon name="mail" /> E-mail
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="admin-articles">
|
||||
<div className="admin-articles-filters">
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
className={statusFilter === filter.key ? 'active' : ''}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-card"><p className="admin-empty">Wczytywanie propozycji...</p></div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="admin-card"><p className="admin-empty">Brak propozycji w tym widoku.</p></div>
|
||||
) : (
|
||||
<div className="admin-articles-list">
|
||||
{items.map((item) => {
|
||||
const expanded = expandedId === item.id;
|
||||
return (
|
||||
<article className={`admin-article-card status-${STATUS_CLASS[item.status]}`} key={item.id}>
|
||||
<div className="admin-article-head">
|
||||
<div className="admin-article-title">
|
||||
<span className="admin-article-topic"><Icon name="document" /> {topicTitle(item.topicSlug)}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.excerpt}</p>
|
||||
</div>
|
||||
<em className={`admin-status ${STATUS_CLASS[item.status]}`}>{STATUS_LABEL[item.status]}</em>
|
||||
</div>
|
||||
|
||||
<div className="admin-article-meta">
|
||||
<span><Icon name="user" /> {item.authorName}{item.authorNick ? ` (@${item.authorNick})` : ''}</span>
|
||||
<span><Icon name="clock" /> {item.readMinutes} min • {formatDate(item.createdAt)}</span>
|
||||
{item.attachments.length > 0 && <span><Icon name="camera" /> {item.attachments.length} zał.</span>}
|
||||
</div>
|
||||
|
||||
<div className="admin-article-contact">
|
||||
<span className="admin-article-contact-label"><Icon name="phone" /> Kontakt:</span>
|
||||
<strong>{item.contactName || item.authorName}</strong>
|
||||
<a href={`mailto:${item.contactEmail || item.authorEmail}`}>{item.contactEmail || item.authorEmail}</a>
|
||||
{item.contactPhone && <a href={`tel:${item.contactPhone.replace(/\s+/g, '')}`}>{item.contactPhone}</a>}
|
||||
</div>
|
||||
|
||||
{item.status === 'REJECTED' && item.rejectionReason && (
|
||||
<p className="admin-article-reject-note"><Icon name="warning" /> Powód odrzucenia: {item.rejectionReason}</p>
|
||||
)}
|
||||
{item.reviewedByEmail && (
|
||||
<p className="admin-article-reviewed">Sprawdzone przez {item.reviewedByEmail} • {formatDate(item.reviewedAt)}</p>
|
||||
)}
|
||||
|
||||
<button type="button" className="admin-article-toggle" onClick={() => setExpandedId(expanded ? null : item.id)}>
|
||||
<Icon name="eye" /> {expanded ? 'Ukryj podgląd' : 'Pokaż pełną treść'}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="admin-article-preview">
|
||||
<SafeHtml className="guide-user-content" html={item.content} />
|
||||
{item.attachments.length > 0 && (
|
||||
<div className="admin-article-attachments">
|
||||
{item.attachments.map((src, index) => (
|
||||
<img key={index} src={src} alt={`Załącznik ${index + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{decision?.id === item.id ? (
|
||||
<div className="admin-article-decision">
|
||||
{decision.type === 'reject' && (
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Powód odrzucenia (opcjonalny) - trafi do autora w powiadomieniu."
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
{channelPicker}
|
||||
<div className="admin-article-actions">
|
||||
<button type="button" className="admin-btn" onClick={() => setDecision(null)}>Anuluj</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn ${decision.type === 'approve' ? 'approve' : 'reject'}`}
|
||||
disabled={busyId === item.id}
|
||||
onClick={() => confirmDecision(item.id, decision.type)}
|
||||
>
|
||||
{decision.type === 'approve' ? 'Potwierdź publikację i powiadom' : 'Potwierdź odrzucenie i powiadom'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-article-actions">
|
||||
{item.status === 'PUBLISHED' && (
|
||||
<Link className="admin-btn view" to={articlePath(item.id)} target="_blank" rel="noopener">
|
||||
Zobacz na stronie
|
||||
</Link>
|
||||
)}
|
||||
{item.status !== 'PUBLISHED' && (
|
||||
<button type="button" className="admin-btn approve" disabled={busyId === item.id} onClick={() => openDecision(item.id, 'approve')}>
|
||||
<Icon name="check" /> Akceptuj i publikuj
|
||||
</button>
|
||||
)}
|
||||
{item.status !== 'REJECTED' && (
|
||||
<button type="button" className="admin-btn reject" disabled={busyId === item.id} onClick={() => openDecision(item.id, 'reject')}>
|
||||
Odrzuć
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="admin-btn danger" disabled={busyId === item.id} onClick={() => remove(item.id)}>
|
||||
<Icon name="trash" /> Usuń
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user