Import source: app/src/admin/AnalyticsPanel

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:23:52 +03:30
parent a6c616c274
commit fd84842f8a
+375
View File
@@ -0,0 +1,375 @@
import { useState, useEffect } from 'react';
import { useNav, useToast, EmptyState, pillBtn, inputStyle } from '../../teknav-ui.jsx';
import { adminApi } from '../lib/admin-api.js';
import { BRAND, PanelState, SEO_SEVERITY, actionBtn } from './Shared.jsx';
function SeoAuditPanel() {
const { navigate } = useNav();
const [audit, setAudit] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [page, setPage] = useState(0);
const PAGE_SIZE = 15;
useEffect(() => {
setLoading(true);
setError(false);
adminApi.getSeoAudit()
.then(setAudit)
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
if (loading || error || !audit) return <PanelState loading={loading} error={error} empty={!loading && !error} emptyTitle="داده ممیزی از سرور دریافت نشد" />;
const issues = (audit.items ?? []).filter(item => item.missing?.length || item.needsRefresh);
const totalIssues = issues.reduce((sum, item) => sum + (item.missing?.length ?? 0) + (item.needsRefresh ? 1 : 0), 0);
const pageItems = issues.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(issues.length / PAGE_SIZE);
const getSeverity = (key) => {
if (['metaDescription', 'ogTitle', 'ogDescription'].includes(key)) return 'error';
if (['keywords', 'ogImage', 'canonicalPath'].includes(key)) return 'warning';
return 'info';
};
return (
<div style={{ direction: 'rtl' }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 20, flexWrap: 'wrap' }}>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 10, padding: '10px 18px', fontSize: 14, fontWeight: 700, color: BRAND.ink }}>
{totalIssues.toLocaleString('fa-IR')} مشکل یافت شد در {issues.length.toLocaleString('fa-IR')} مقاله
</div>
{issues.length === 0 && <span style={{ color: '#2F8F6B', fontWeight: 700, fontSize: 14 }}>✓ همه مقاله‌ها سالم هستند</span>}
</div>
{issues.length > 0 && (
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, fontFamily: 'Vazirmatn,sans-serif' }}>
<thead>
<tr style={{ background: '#F4EFE6', borderBottom: '1px solid #E4DDD2' }}>
{['مقاله', 'مشکلات', 'شدت'].map(h => <th key={h} style={{ padding: '10px 16px', textAlign: 'right', color: '#5F6B6D', fontWeight: 600 }}>{h}</th>)}
</tr>
</thead>
<tbody>
{pageItems.map((item, i) => {
const allKeys = [...(item.missing ?? []), ...(item.needsRefresh ? ['needsRefresh'] : [])];
const worstSeverity = allKeys.some(k => getSeverity(k) === 'error') ? 'error' : allKeys.some(k => getSeverity(k) === 'warning') ? 'warning' : 'info';
const sev = SEO_SEVERITY[worstSeverity];
return (
<tr key={item.article.id} style={{ borderBottom: '1px solid #E4DDD2', background: i % 2 === 0 ? '#fff' : '#FDFBF7' }}>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => navigate('/article/' + item.article.slug)} style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: BRAND.primary, fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, fontWeight: 700, textAlign: 'right' }}>
{item.article.title}
</button>
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{allKeys.map(k => {
const s = SEO_SEVERITY[getSeverity(k)];
return <span key={k} style={{ background: s.bg, color: s.color, border: `1px solid ${s.color}33`, borderRadius: 6, padding: '2px 8px', fontSize: 11, fontWeight: 600 }}>{k}</span>;
})}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ background: sev.bg, color: sev.color, border: `1px solid ${sev.color}33`, borderRadius: 6, padding: '4px 10px', fontSize: 11, fontWeight: 700 }}>{sev.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
{totalPages > 1 && (
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', padding: 16, borderTop: '1px solid #E4DDD2' }}>
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', opacity: page === 0 ? 0.5 : 1 }}>قبلی</button>
<span style={{ fontSize: 13, color: '#5F6B6D', alignSelf: 'center' }}>صفحه {(page + 1).toLocaleString('fa-IR')} از {totalPages.toLocaleString('fa-IR')}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', opacity: page >= totalPages - 1 ? 0.5 : 1 }}>بعدی</button>
</div>
)}
</div>
)}
</div>
);
}
function EngagementAnalyticsPanel() {
const [engagement, setEngagement] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
setLoading(true);
setError(false);
adminApi.getEngagementAnalytics()
.then(setEngagement)
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
if (loading || error || !engagement) return <PanelState loading={loading} error={error} empty={!loading && !error} emptyTitle="هنوز داده تعامل از API دریافت نشده است" />;
const byType = engagement.byType ?? [];
const totalEvents = byType.reduce((sum, item) => sum + Number(item.count ?? 0), 0);
const topArticles = (engagement.topArticles ?? []).slice(0, 5);
const weeklyData = engagement.weeklyData ?? [];
const barMax = Math.max(...weeklyData.map(d => d.count ?? 0), 1);
return (
<div style={{ display: 'grid', gap: 18, direction: 'rtl' }}>
{weeklyData.length > 0 && (
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
<h3 style={{ margin: '0 0 16px', fontSize: 15, color: BRAND.ink }}>رویدادهای ۷ روز گذشته</h3>
<div style={{ display: 'flex', gap: 4, alignItems: 'flex-end', height: 100 }}>
{weeklyData.map((d, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<div style={{ fontSize: 10, color: '#5F6B6D' }}>{Number(d.count ?? 0).toLocaleString('fa-IR')}</div>
<div style={{ width: '100%', background: BRAND.primary, borderRadius: '4px 4px 0 0', height: `${Math.round((d.count ?? 0) / barMax * 70)}px`, minHeight: 2 }} />
<div style={{ fontSize: 9, color: '#5F6B6D', transform: 'rotate(-35deg)', transformOrigin: 'center', whiteSpace: 'nowrap' }}>{d.date ?? ''}</div>
</div>
))}
</div>
</div>
)}
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, color: BRAND.ink }}>انواع رویداد</h3>
{byType.length === 0 ? <EmptyState title="هنوز رویدادی ثبت نشده است" /> : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, fontFamily: 'Vazirmatn,sans-serif' }}>
<thead>
<tr style={{ borderBottom: '1px solid #E4DDD2' }}>
{['نوع رویداد', 'تعداد', 'درصد'].map(h => <th key={h} style={{ padding: '8px 12px', textAlign: 'right', color: '#5F6B6D', fontWeight: 600 }}>{h}</th>)}
</tr>
</thead>
<tbody>
{byType.map(item => (
<tr key={item.type} style={{ borderBottom: '1px solid #E4DDD2' }}>
<td style={{ padding: '8px 12px', color: BRAND.ink, fontWeight: 600 }}>{item.type}</td>
<td style={{ padding: '8px 12px', color: '#263238' }}>{Number(item.count ?? 0).toLocaleString('fa-IR')}</td>
<td style={{ padding: '8px 12px', color: '#5F6B6D' }}>
{totalEvents > 0 ? `${Math.round(Number(item.count ?? 0) / totalEvents * 100)}٪` : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{topArticles.length > 0 && (
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, color: BRAND.ink }}>۵ مقاله برتر بر اساس تعامل</h3>
{topArticles.map((article, i) => (
<div key={article.id ?? i} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #E4DDD2' }}>
<span style={{ fontSize: 13, fontWeight: 700, color: BRAND.ink }}>{article.title}</span>
<span style={{ fontSize: 12, color: '#5F6B6D', whiteSpace: 'nowrap' }}>{Number(article.eventCount ?? 0).toLocaleString('fa-IR')} رویداد</span>
</div>
))}
</div>
)}
</div>
);
}
function Phase9AnalyticsPanel() {
const [subTab, setSubTab] = useState('overview');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
adminApi.getAnalytics()
.then(analytics => { if (!cancelled) setData(analytics); })
.catch(() => { if (!cancelled) setError(true); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const tabs = [
{ id: 'overview', label: 'مروری' },
{ id: 'engagement', label: 'تعامل' },
];
const tabBtn = (id) => ({
background: subTab === id ? BRAND.primary : 'transparent',
color: subTab === id ? '#fff' : '#5F6B6D',
border: `1px solid ${subTab === id ? BRAND.primary : '#E4DDD2'}`,
borderRadius: 8, padding: '6px 16px', fontSize: 12, fontWeight: 600,
cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', transition: 'all 0.15s',
});
return (
<div style={{ direction: 'rtl' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{tabs.map(t => <button key={t.id} style={tabBtn(t.id)} onClick={() => setSubTab(t.id)}>{t.label}</button>)}
</div>
{subTab === 'engagement' ? <EngagementAnalyticsPanel /> : (
<>
{(loading || error || !data) ? <PanelState loading={loading} error={error} empty={!loading && !error} emptyTitle="هنوز داده تحلیلی از API دریافت نشده است" /> : (
<div style={{ display: 'grid', gap: 18 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 12 }}>
{[['نظرها', data.comments], ['ذخیره‌ها', data.saved], ['مشترکان خبرنامه', data.subscribers]].map(([label, value]) => (
<div key={label} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 16 }}>
<div style={{ fontSize: 12, color: '#5F6B6D' }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 900, color: BRAND.primary }}>{Number(value ?? 0).toLocaleString('fa-IR')}</div>
</div>
))}
</div>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, color: BRAND.ink }}>مقاله‌های برتر</h3>
{(data.topArticles ?? []).length === 0 ? <EmptyState title="مقاله‌ای برای تحلیل وجود ندارد" /> : data.topArticles.map((article) => (
<div key={article.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '10px 0', borderBottom: '1px solid #E4DDD2' }}>
<span style={{ fontSize: 13, fontWeight: 800, color: BRAND.ink }}>{article.title}</span>
<span style={{ fontSize: 12, color: '#5F6B6D' }}>{Number(article.views ?? 0).toLocaleString('fa-IR')} بازدید</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
);
}
function JobsPanel() {
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [actionMsg, setActionMsg] = useState('');
const load = (status = filter) => {
setLoading(true);
adminApi.listJobs(status ? { status } : {}).then((data) => {
setItems(data);
setLoading(false);
}).catch(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const moderate = async (id, status) => {
await adminApi.moderateJob(id, { status });
setActionMsg(`وضعیت به "${status}" تغییر یافت`);
setTimeout(() => setActionMsg(''), 2500);
load();
};
const statusLabel = { pending: 'در انتظار', approved: 'تأیید شده', rejected: 'رد شده' };
const statusColor = { pending: '#D49A2A', approved: '#0F6B73', rejected: '#C46A4D' };
return (
<div style={{ direction: 'rtl' }}>
<div style={{ display: 'flex', gap: 10, marginBottom: 18, flexWrap: 'wrap', alignItems: 'center' }}>
<select value={filter} onChange={(e) => { setFilter(e.target.value); load(e.target.value); }} style={{ ...inputStyle, width: 160 }}>
<option value="">همه وضعیت‌ها</option>
<option value="pending">در انتظار</option>
<option value="approved">تأیید شده</option>
<option value="rejected">رد شده</option>
</select>
{actionMsg && <span style={{ color: '#0F6B73', fontSize: 13, fontWeight: 600 }}>{actionMsg}</span>}
</div>
{loading && <div style={{ color: BRAND.muted, fontSize: 14 }}>در حال بارگذاری...</div>}
{!loading && items.length === 0 && <div style={{ color: BRAND.muted, fontSize: 14, textAlign: 'center', padding: 40 }}>هیچ آگهی یافت نشد</div>}
{!loading && items.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#F4EFE6' }}>
{['شرکت', 'عنوان', 'موقعیت', 'وضعیت', 'تاریخ', 'عملیات'].map((h) => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'right', fontWeight: 700, color: BRAND.ink, borderBottom: `1px solid ${BRAND.line}` }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{items.map((job) => (
<tr key={job.id} style={{ borderBottom: `1px solid ${BRAND.line}` }}>
<td style={{ padding: '10px 14px' }}>{job.company}</td>
<td style={{ padding: '10px 14px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{job.title}</td>
<td style={{ padding: '10px 14px' }}>{job.location}{job.remote ? ' (ریموت)' : ''}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ background: (statusColor[job.status] ?? '#999') + '22', color: statusColor[job.status] ?? '#999', borderRadius: 6, padding: '2px 10px', fontWeight: 700, fontSize: 12 }}>
{statusLabel[job.status] ?? job.status}
</span>
</td>
<td style={{ padding: '10px 14px', color: BRAND.muted }}>{new Date(job.createdAt).toLocaleDateString('fa-IR')}</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }}>
{job.status !== 'approved' && (
<button onClick={() => moderate(job.id, 'approved')} style={{ ...pillBtn, background: '#0F6B7318', color: '#0F6B73', fontSize: 11, padding: '4px 10px' }}>تأیید</button>
)}
{job.status !== 'rejected' && (
<button onClick={() => moderate(job.id, 'rejected')} style={{ ...pillBtn, background: '#C46A4D18', color: '#C46A4D', fontSize: 11, padding: '4px 10px' }}>رد</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function ClientErrorsPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminApi.getClientErrors().then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
}, []);
if (loading) return <div style={{ color: BRAND.muted, fontSize: 14 }}>در حال بارگذاری...</div>;
if (!data) return <div style={{ color: BRAND.muted, fontSize: 14, textAlign: 'center', padding: 40 }}>خطا در بارگذاری اطلاعات</div>;
const maxCount = Math.max(...(data.topErrors?.map((e) => e.count) ?? [1]), 1);
return (
<div style={{ direction: 'rtl' }}>
{data.dailyCounts?.length > 0 && (
<div style={{ background: '#fff', border: `1px solid ${BRAND.line}`, borderRadius: 12, padding: 20, marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: BRAND.ink, marginBottom: 12 }}>خطاها در ۳۰ روز گذشته</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 60 }}>
{data.dailyCounts.slice(-30).map((d) => {
const h = Math.max(4, Math.round((d.count / maxCount) * 56));
return (
<div key={d.day} title={`${d.day}: ${d.count} خطا`} style={{ flex: 1, height: h, background: '#C46A4D88', borderRadius: 2, minWidth: 4 }} />
);
})}
</div>
</div>
)}
<div style={{ background: '#fff', border: `1px solid ${BRAND.line}`, borderRadius: 12, padding: 20, marginBottom: 20 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: BRAND.ink, marginBottom: 12 }}>پرتکرارترین خطاها</div>
{(data.topErrors ?? []).length === 0 && <div style={{ color: BRAND.muted, fontSize: 13 }}>خطایی ثبت نشده است</div>}
{(data.topErrors ?? []).map((err, i) => (
<div key={i} style={{ borderBottom: `1px solid ${BRAND.line}`, padding: '8px 0', display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 12, color: BRAND.ink, flex: 1, wordBreak: 'break-all' }}>{err.message}</span>
<div style={{ display: 'flex', gap: 12, flexShrink: 0, fontSize: 11, color: BRAND.muted }}>
<span style={{ color: '#C46A4D', fontWeight: 700 }}>{err.count} بار</span>
<span>{new Date(err.lastSeen).toLocaleDateString('fa-IR')}</span>
</div>
</div>
))}
</div>
<div style={{ background: '#fff', border: `1px solid ${BRAND.line}`, borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: BRAND.ink, marginBottom: 12 }}>آخرین خطاهای کلاینت</div>
{(data.recent ?? []).length === 0 && <div style={{ color: BRAND.muted, fontSize: 13 }}>خطایی ثبت نشده است</div>}
{(data.recent ?? []).map((err) => (
<div key={err.id} style={{ borderBottom: `1px solid ${BRAND.line}`, padding: '8px 0', fontSize: 12 }}>
<div style={{ color: BRAND.ink, marginBottom: 2 }}>{err.message}</div>
<div style={{ color: BRAND.muted, display: 'flex', gap: 12 }}>
{err.url && <span>{err.url}</span>}
<span>{new Date(err.createdAt).toLocaleDateString('fa-IR')}</span>
</div>
</div>
))}
</div>
</div>
);
}
export { SeoAuditPanel, EngagementAnalyticsPanel, Phase9AnalyticsPanel, JobsPanel, ClientErrorsPanel };