Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
1246 lines
52 KiB
TypeScript
1246 lines
52 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import {
|
|
AdminBackLink,
|
|
AdminEmptyState,
|
|
AdminErrorState,
|
|
AdminInfoRow,
|
|
AdminLoadingBlock,
|
|
AdminPageHeader,
|
|
AdminPageShell,
|
|
AdminSecondaryButton,
|
|
AdminSection,
|
|
AdminStatusPill,
|
|
formatAdminDate,
|
|
formatAdminPercent,
|
|
getAdminErrorMessage,
|
|
} from '@/components/admin';
|
|
import { Input } from '@/components/ui/input';
|
|
import { PCQ24_QUESTIONS, PCQ_LIKERT_OPTIONS } from '@/config/pcq24';
|
|
import {
|
|
downloadAdminUserAssignmentsExport,
|
|
fetchAdminUserAssessments,
|
|
fetchAdminUserAssignmentDetail,
|
|
fetchAdminUserAssignments,
|
|
fetchAdminUserChatSessionDetail,
|
|
fetchAdminUserChatSessions,
|
|
fetchAdminUserDetail,
|
|
fetchAdminUserOverview,
|
|
type AdminAssessmentPhaseView,
|
|
type AdminAssignmentDetail,
|
|
type AdminAssignmentListItem,
|
|
type AdminAssignmentsQuery,
|
|
type AdminChatMessageView,
|
|
type AdminChatSessionDetail,
|
|
type AdminChatSessionListItem,
|
|
type AdminLearningProgress,
|
|
type AdminAssignmentsSummary,
|
|
type AdminUserAssessments,
|
|
type AdminUserDetail,
|
|
type AdminUserOverview,
|
|
} from '@/services/adminService';
|
|
import { BackendError } from '@/services/backend';
|
|
import { ROUTES } from '@/utils/constants';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const STEP_LABELS: Record<string, string> = {
|
|
registered: 'ثبتنام',
|
|
pre_test_done: 'پیشآزمون انجامشده',
|
|
training_in_progress: 'در حال آموزش',
|
|
training_completed: 'پایان آموزش',
|
|
post_test_done: 'پسآزمون',
|
|
completed: 'تکمیل دوره',
|
|
};
|
|
|
|
const ASSIGNMENT_STATUS_LABELS: Record<string, string> = {
|
|
NOT_STARTED: 'شروعنشده',
|
|
ACTIVE: 'فعال',
|
|
IN_PROGRESS: 'در حال انجام',
|
|
REPORTED: 'گزارششده',
|
|
COMPLETED: 'تکمیلشده',
|
|
};
|
|
|
|
const ASSIGNMENT_TYPE_LABELS: Record<string, string> = {
|
|
ONE_TIME: 'یکباره',
|
|
DAILY: 'روزانه',
|
|
ONGOING: 'پیوسته',
|
|
};
|
|
|
|
const ASSIGNMENT_STATUS_OPTIONS = [
|
|
'NOT_STARTED',
|
|
'ACTIVE',
|
|
'IN_PROGRESS',
|
|
'REPORTED',
|
|
'COMPLETED',
|
|
] as const;
|
|
|
|
const ASSIGNMENT_TYPE_OPTIONS = ['ONE_TIME', 'DAILY', 'ONGOING'] as const;
|
|
|
|
const ACTIVITY_TYPE_LABELS: Record<string, string> = {
|
|
session_completed: 'جلسه',
|
|
assignment_activated: 'تخصیص تکلیف',
|
|
assignment_completed: 'تکمیل تکلیف',
|
|
assessment_submitted: 'آزمون',
|
|
chat_started: 'گفتگو',
|
|
};
|
|
|
|
const ASSESSMENT_OVERALL_LABELS: Record<string, string> = {
|
|
none: 'آزمونی ثبت نشده',
|
|
partial: 'ناقص',
|
|
complete: 'کامل',
|
|
};
|
|
|
|
const DIMENSION_LABELS: Record<string, string> = {
|
|
efficacy: 'خودکارآمدی',
|
|
hope: 'امید',
|
|
resilience: 'تابآوری',
|
|
optimism: 'خوشبینی',
|
|
};
|
|
|
|
const PCQ_ANSWER_LABELS: Record<number, string> = Object.fromEntries(
|
|
PCQ_LIKERT_OPTIONS.map((option) => [Number(option.value), option.label]),
|
|
) as Record<number, string>;
|
|
|
|
const PCQ_QUESTION_TEXT: Record<string, string> = Object.fromEntries(
|
|
PCQ24_QUESTIONS.map((question) => [question.id, question.text]),
|
|
);
|
|
|
|
const filterSelectClass =
|
|
'h-9 rounded-md border border-slate-200 bg-white px-2 text-sm dark:border-slate-600 dark:bg-slate-900';
|
|
|
|
function formatScore(value: number | null | undefined): string {
|
|
if (value == null) return '—';
|
|
return value.toLocaleString('fa-IR');
|
|
}
|
|
|
|
function pcqAnswerLabel(rawValue: number): string {
|
|
return PCQ_ANSWER_LABELS[rawValue] ?? String(rawValue);
|
|
}
|
|
|
|
function AssignmentStatusBadge({ status }: { status: string }) {
|
|
const tone =
|
|
status === 'COMPLETED'
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
|
: status === 'IN_PROGRESS' || status === 'REPORTED' || status === 'ACTIVE'
|
|
? 'bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300';
|
|
|
|
return (
|
|
<span className={cn('inline-flex rounded-md px-2 py-0.5 text-xs', tone)}>
|
|
{ASSIGNMENT_STATUS_LABELS[status] ?? status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function AssessmentPhasePanel({
|
|
title,
|
|
phase,
|
|
}: {
|
|
title: string;
|
|
phase: AdminAssessmentPhaseView | null;
|
|
}) {
|
|
if (!phase) {
|
|
return (
|
|
<AdminSection title={title}>
|
|
<AdminLoadingBlock label="در حال بارگذاری آزمون…" rows={2} />
|
|
</AdminSection>
|
|
);
|
|
}
|
|
|
|
if (!phase.exists) {
|
|
return (
|
|
<AdminSection title={title}>
|
|
<AdminEmptyState title="این آزمون هنوز ثبت نشده است" />
|
|
</AdminSection>
|
|
);
|
|
}
|
|
|
|
const incomplete = phase.answerCount < phase.expectedAnswerCount || phase.scores == null;
|
|
|
|
return (
|
|
<AdminSection title={title}>
|
|
<div className="flex flex-wrap items-center gap-2 pb-2 text-xs text-slate-500">
|
|
<AdminStatusPill ok={!incomplete} label={incomplete ? 'ناقص' : 'کامل'} />
|
|
<span>ثبت: {formatAdminDate(phase.submittedAt)}</span>
|
|
<span>
|
|
{phase.answerCount.toLocaleString('fa-IR')} از{' '}
|
|
{phase.expectedAnswerCount.toLocaleString('fa-IR')} پاسخ
|
|
</span>
|
|
</div>
|
|
|
|
{phase.scores ? (
|
|
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
|
|
{(
|
|
[
|
|
['نمره کل', phase.scores.total],
|
|
['خودکارآمدی', phase.scores.efficacy],
|
|
['امید', phase.scores.hope],
|
|
['تابآوری', phase.scores.resilience],
|
|
['خوشبینی', phase.scores.optimism],
|
|
] as const
|
|
).map(([label, score]) => (
|
|
<div key={label} className="rounded-lg bg-slate-50 px-3 py-2 dark:bg-slate-900">
|
|
<p className="text-[11px] text-slate-500">{label}</p>
|
|
<p className="font-semibold tabular-nums">{formatScore(score)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-slate-500">امتیازی ثبت نشده است.</p>
|
|
)}
|
|
|
|
{phase.answers.length === 0 ? (
|
|
<AdminEmptyState title="پاسخی ثبت نشده است" />
|
|
) : (
|
|
<ol className="mt-3 divide-y divide-slate-100 dark:divide-slate-800">
|
|
{phase.answers.map((answer) => {
|
|
const questionText = PCQ_QUESTION_TEXT[answer.questionId];
|
|
return (
|
|
<li key={answer.questionId} className="space-y-1 py-2.5">
|
|
<p className="text-sm text-slate-900 dark:text-slate-50">
|
|
سوال {answer.questionNumber.toLocaleString('fa-IR')}:{' '}
|
|
<span className="font-medium">{pcqAnswerLabel(answer.rawValue)}</span>
|
|
</p>
|
|
{questionText ? (
|
|
<p className="text-xs leading-5 text-slate-500 dark:text-slate-400">
|
|
{questionText}
|
|
</p>
|
|
) : null}
|
|
<p className="text-[11px] text-slate-400">
|
|
{DIMENSION_LABELS[answer.dimension] ?? answer.dimension}
|
|
</p>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
)}
|
|
</AdminSection>
|
|
);
|
|
}
|
|
|
|
function ChatMessageBubble({ message }: { message: AdminChatMessageView }) {
|
|
const isUser = message.role === 'user';
|
|
return (
|
|
<div className={cn('flex', isUser ? 'justify-start' : 'justify-end')}>
|
|
<div
|
|
className={cn(
|
|
'max-w-[85%] rounded-xl px-3 py-2 text-sm whitespace-pre-wrap break-words',
|
|
isUser
|
|
? 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-50'
|
|
: 'bg-emerald-50 text-emerald-950 dark:bg-emerald-950 dark:text-emerald-100',
|
|
)}
|
|
>
|
|
<div className="mb-1 flex items-center gap-2 text-[11px] opacity-70">
|
|
<span>{isUser ? 'کاربر' : message.role === 'assistant' ? 'دستیار' : message.role}</span>
|
|
<span dir="ltr">{formatAdminDate(message.createdAt)}</span>
|
|
</div>
|
|
{message.content}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AdminUserDetailPage() {
|
|
const { externalId = '' } = useParams<{ externalId: string }>();
|
|
const [user, setUser] = useState<AdminUserDetail | null>(null);
|
|
const [overview, setOverview] = useState<AdminUserOverview | null>(null);
|
|
const [assessments, setAssessments] = useState<AdminUserAssessments | null>(null);
|
|
const [learningProgress, setLearningProgress] = useState<AdminLearningProgress | null>(null);
|
|
const [assignmentSummary, setAssignmentSummary] = useState<AdminAssignmentsSummary | null>(
|
|
null,
|
|
);
|
|
const [assignments, setAssignments] = useState<AdminAssignmentListItem[]>([]);
|
|
const [selectedAssignment, setSelectedAssignment] = useState<AdminAssignmentDetail | null>(
|
|
null,
|
|
);
|
|
const [chatSessions, setChatSessions] = useState<AdminChatSessionListItem[]>([]);
|
|
const [chatPage, setChatPage] = useState(1);
|
|
const [chatTotalPages, setChatTotalPages] = useState(0);
|
|
const [chatTotal, setChatTotal] = useState(0);
|
|
const [selectedChat, setSelectedChat] = useState<AdminChatSessionDetail | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [overviewLoading, setOverviewLoading] = useState(false);
|
|
const [assessmentsLoading, setAssessmentsLoading] = useState(false);
|
|
const [assignmentsLoading, setAssignmentsLoading] = useState(false);
|
|
const [assignmentDetailLoading, setAssignmentDetailLoading] = useState(false);
|
|
const [chatsLoading, setChatsLoading] = useState(false);
|
|
const [chatDetailLoading, setChatDetailLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [overviewError, setOverviewError] = useState<string | null>(null);
|
|
const [assessmentsError, setAssessmentsError] = useState<string | null>(null);
|
|
const [assignmentsError, setAssignmentsError] = useState<string | null>(null);
|
|
const [assignmentDetailError, setAssignmentDetailError] = useState<string | null>(null);
|
|
const [exportLoading, setExportLoading] = useState(false);
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
const [chatsError, setChatsError] = useState<string | null>(null);
|
|
const [chatDetailError, setChatDetailError] = useState<string | null>(null);
|
|
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
const [assessmentTab, setAssessmentTab] = useState<'pre' | 'post'>('pre');
|
|
const [assignmentSearchInput, setAssignmentSearchInput] = useState('');
|
|
const [assignmentSearch, setAssignmentSearch] = useState('');
|
|
const [assignmentStatusFilter, setAssignmentStatusFilter] = useState<string>('');
|
|
const [assignmentTypeFilter, setAssignmentTypeFilter] = useState<string>('');
|
|
const [assignmentSortBy, setAssignmentSortBy] =
|
|
useState<AdminAssignmentsQuery['sortBy']>('sessionOrder');
|
|
const [assignmentSortDir, setAssignmentSortDir] =
|
|
useState<AdminAssignmentsQuery['sortDir']>('asc');
|
|
|
|
const hasAssignmentFilters = Boolean(
|
|
assignmentSearch || assignmentStatusFilter || assignmentTypeFilter,
|
|
);
|
|
|
|
const buildAssignmentsQuery = useCallback((): AdminAssignmentsQuery => {
|
|
const query: AdminAssignmentsQuery = {
|
|
sortBy: assignmentSortBy,
|
|
sortDir: assignmentSortDir,
|
|
};
|
|
if (assignmentStatusFilter) {
|
|
query.status = assignmentStatusFilter as NonNullable<AdminAssignmentsQuery['status']>;
|
|
}
|
|
if (assignmentTypeFilter) {
|
|
query.assignmentType =
|
|
assignmentTypeFilter as NonNullable<AdminAssignmentsQuery['assignmentType']>;
|
|
}
|
|
if (assignmentSearch.trim()) {
|
|
query.search = assignmentSearch.trim();
|
|
}
|
|
return query;
|
|
}, [
|
|
assignmentSearch,
|
|
assignmentSortBy,
|
|
assignmentSortDir,
|
|
assignmentStatusFilter,
|
|
assignmentTypeFilter,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => {
|
|
setAssignmentSearch(assignmentSearchInput.trim());
|
|
}, 400);
|
|
return () => window.clearTimeout(timer);
|
|
}, [assignmentSearchInput]);
|
|
|
|
const loadAssignments = useCallback(async () => {
|
|
if (!externalId) return;
|
|
setAssignmentsLoading(true);
|
|
setAssignmentsError(null);
|
|
setExportError(null);
|
|
try {
|
|
const result = await fetchAdminUserAssignments(externalId, buildAssignmentsQuery());
|
|
setLearningProgress(result.learningProgress);
|
|
setAssignmentSummary(result.summary);
|
|
setAssignments(result.items);
|
|
} catch (err) {
|
|
setLearningProgress(null);
|
|
setAssignmentSummary(null);
|
|
setAssignments([]);
|
|
setAssignmentsError(getAdminErrorMessage(err, 'دریافت برنامهٔ آموزشی ناموفق بود.'));
|
|
} finally {
|
|
setAssignmentsLoading(false);
|
|
}
|
|
}, [buildAssignmentsQuery, externalId]);
|
|
|
|
const clearAssignmentFilters = useCallback(() => {
|
|
setAssignmentSearchInput('');
|
|
setAssignmentSearch('');
|
|
setAssignmentStatusFilter('');
|
|
setAssignmentTypeFilter('');
|
|
setAssignmentSortBy('sessionOrder');
|
|
setAssignmentSortDir('asc');
|
|
}, []);
|
|
|
|
const openAssignmentDetail = useCallback(
|
|
async (assignmentId: string) => {
|
|
if (!externalId) return;
|
|
setAssignmentDetailLoading(true);
|
|
setAssignmentDetailError(null);
|
|
setSelectedAssignment(null);
|
|
try {
|
|
const detail = await fetchAdminUserAssignmentDetail(externalId, assignmentId);
|
|
setSelectedAssignment(detail);
|
|
} catch (err) {
|
|
if (err instanceof BackendError && err.status === 404) {
|
|
setAssignmentDetailError('تکلیف یافت نشد.');
|
|
} else {
|
|
setAssignmentDetailError(getAdminErrorMessage(err, 'دریافت جزئیات تکلیف ناموفق بود.'));
|
|
}
|
|
} finally {
|
|
setAssignmentDetailLoading(false);
|
|
}
|
|
},
|
|
[externalId],
|
|
);
|
|
|
|
const exportAssignments = useCallback(async () => {
|
|
if (!externalId || exportLoading) return;
|
|
setExportLoading(true);
|
|
setExportError(null);
|
|
try {
|
|
await downloadAdminUserAssignmentsExport(externalId, buildAssignmentsQuery());
|
|
} catch (err) {
|
|
setExportError(getAdminErrorMessage(err, 'دانلود فایل Excel ناموفق بود.'));
|
|
} finally {
|
|
setExportLoading(false);
|
|
}
|
|
}, [buildAssignmentsQuery, externalId, exportLoading]);
|
|
|
|
const loadChatSessions = useCallback(
|
|
async (page: number) => {
|
|
if (!externalId) return;
|
|
setChatsLoading(true);
|
|
setChatsError(null);
|
|
setSelectedChat(null);
|
|
setChatDetailError(null);
|
|
try {
|
|
const list = await fetchAdminUserChatSessions(externalId, {
|
|
page,
|
|
pageSize: 10,
|
|
sortBy: 'startedAt',
|
|
sortDir: 'desc',
|
|
});
|
|
setChatSessions(list.items);
|
|
setChatPage(list.pagination.page);
|
|
setChatTotalPages(list.pagination.totalPages);
|
|
setChatTotal(list.pagination.total);
|
|
} catch (err) {
|
|
setChatSessions([]);
|
|
setChatsError(getAdminErrorMessage(err, 'دریافت جلسات چت ناموفق بود.'));
|
|
} finally {
|
|
setChatsLoading(false);
|
|
}
|
|
},
|
|
[externalId],
|
|
);
|
|
|
|
const openChatSession = useCallback(
|
|
async (sessionId: string) => {
|
|
if (!externalId) return;
|
|
setChatDetailLoading(true);
|
|
setChatDetailError(null);
|
|
setSelectedChat(null);
|
|
try {
|
|
const detail = await fetchAdminUserChatSessionDetail(externalId, sessionId);
|
|
setSelectedChat(detail);
|
|
} catch (err) {
|
|
if (err instanceof BackendError && err.status === 404) {
|
|
setChatDetailError('جلسه چت یافت نشد.');
|
|
} else {
|
|
setChatDetailError(getAdminErrorMessage(err, 'دریافت پیامهای جلسه ناموفق بود.'));
|
|
}
|
|
} finally {
|
|
setChatDetailLoading(false);
|
|
}
|
|
},
|
|
[externalId],
|
|
);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!externalId) {
|
|
setError('شناسه کاربر نامعتبر است.');
|
|
setErrorCode('VALIDATION_ERROR');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setAssessmentsLoading(true);
|
|
setOverviewLoading(true);
|
|
setError(null);
|
|
setAssessmentsError(null);
|
|
setOverviewError(null);
|
|
setAssignmentsError(null);
|
|
setExportError(null);
|
|
setChatsError(null);
|
|
setChatDetailError(null);
|
|
setSelectedChat(null);
|
|
setSelectedAssignment(null);
|
|
setAssignmentDetailError(null);
|
|
setOverview(null);
|
|
setLearningProgress(null);
|
|
setAssignmentSummary(null);
|
|
setAssignments([]);
|
|
setAssignmentSearchInput('');
|
|
setAssignmentSearch('');
|
|
setAssignmentStatusFilter('');
|
|
setAssignmentTypeFilter('');
|
|
setAssignmentSortBy('sessionOrder');
|
|
setAssignmentSortDir('asc');
|
|
setErrorCode(null);
|
|
try {
|
|
const detail = await fetchAdminUserDetail(externalId);
|
|
setUser(detail);
|
|
try {
|
|
const nextOverview = await fetchAdminUserOverview(externalId);
|
|
setOverview(nextOverview);
|
|
} catch (overviewErr) {
|
|
setOverview(null);
|
|
setOverviewError(getAdminErrorMessage(overviewErr, 'دریافت داشبورد ناموفق بود.'));
|
|
} finally {
|
|
setOverviewLoading(false);
|
|
}
|
|
try {
|
|
const nextAssessments = await fetchAdminUserAssessments(externalId);
|
|
setAssessments(nextAssessments);
|
|
} catch (assessErr) {
|
|
setAssessments(null);
|
|
setAssessmentsError(getAdminErrorMessage(assessErr, 'دریافت آزمونها ناموفق بود.'));
|
|
}
|
|
await loadChatSessions(1);
|
|
} catch (err) {
|
|
setUser(null);
|
|
setOverview(null);
|
|
setAssessments(null);
|
|
setLearningProgress(null);
|
|
setAssignmentSummary(null);
|
|
setAssignments([]);
|
|
setSelectedAssignment(null);
|
|
setChatSessions([]);
|
|
setOverviewLoading(false);
|
|
if (err instanceof BackendError) {
|
|
setErrorCode(err.code);
|
|
if (err.status === 403) {
|
|
setError('دسترسی غیرمجاز. فقط ادمین میتواند جزئیات کاربران را ببیند.');
|
|
} else if (err.status === 404) {
|
|
setError('کاربر یافت نشد.');
|
|
} else if (err.status === 422 || err.status === 400) {
|
|
setError('شناسه کاربر نامعتبر است.');
|
|
} else {
|
|
setError(getAdminErrorMessage(err, 'دریافت جزئیات کاربر ناموفق بود.'));
|
|
}
|
|
} else {
|
|
setError(getAdminErrorMessage(err, 'دریافت جزئیات کاربر ناموفق بود.'));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
setAssessmentsLoading(false);
|
|
}
|
|
}, [externalId, loadChatSessions]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (!externalId || !user) return;
|
|
void loadAssignments();
|
|
}, [externalId, user, loadAssignments]);
|
|
|
|
return (
|
|
<AdminPageShell maxWidthClassName="max-w-3xl" className="space-y-0">
|
|
<div className="space-y-4">
|
|
<AdminPageHeader
|
|
title="جزئیات کاربر"
|
|
subtitle={
|
|
<span className="break-all text-xs" dir="ltr">
|
|
{externalId || '—'}
|
|
</span>
|
|
}
|
|
actions={<AdminBackLink to={ROUTES.ADMIN} />}
|
|
/>
|
|
|
|
{loading ? <AdminLoadingBlock label="در حال بارگذاری جزئیات کاربر…" rows={5} /> : null}
|
|
|
|
{!loading && error ? (
|
|
<AdminErrorState
|
|
message={error}
|
|
tone={
|
|
errorCode === 'FORBIDDEN' || error.includes('غیرمجاز') ? 'warning' : 'error'
|
|
}
|
|
onRetry={() => void load()}
|
|
/>
|
|
) : null}
|
|
|
|
{!loading && user ? (
|
|
<>
|
|
<AdminSection title="اطلاعات پایه">
|
|
<AdminInfoRow label="نام" value={user.name ?? '— (بدون پروفایل)'} />
|
|
<AdminInfoRow label="ایمیل" value={<span dir="ltr">{user.email ?? '—'}</span>} />
|
|
<AdminInfoRow label="نقش" value={user.role} />
|
|
<AdminInfoRow label="نوع حساب" value={user.isGuest ? 'مهمان' : 'ثبتنامی'} />
|
|
<AdminInfoRow label="سن" value={user.age != null ? String(user.age) : '—'} />
|
|
<AdminInfoRow
|
|
label="پروفایل"
|
|
value={user.profileComplete ? 'کامل' : 'ناقص / ثبتنشده'}
|
|
/>
|
|
<AdminInfoRow label="ثبتنام" value={formatAdminDate(user.createdAt)} />
|
|
<AdminInfoRow label="آخرین فعالیت" value={formatAdminDate(user.lastActivityAt)} />
|
|
</AdminSection>
|
|
|
|
<AdminSection title="داشبورد وضعیت آموزشی">
|
|
{overviewLoading ? (
|
|
<AdminLoadingBlock label="در حال بارگذاری داشبورد…" rows={3} />
|
|
) : null}
|
|
{!overviewLoading && overviewError ? (
|
|
<AdminErrorState
|
|
message={overviewError}
|
|
onRetry={() => {
|
|
setOverviewLoading(true);
|
|
setOverviewError(null);
|
|
void fetchAdminUserOverview(externalId)
|
|
.then((next) => setOverview(next))
|
|
.catch((err) =>
|
|
setOverviewError(getAdminErrorMessage(err, 'دریافت داشبورد ناموفق بود.')),
|
|
)
|
|
.finally(() => setOverviewLoading(false));
|
|
}}
|
|
/>
|
|
) : null}
|
|
{!overviewLoading && !overviewError && !overview ? (
|
|
<AdminEmptyState title="اطلاعاتی برای داشبورد ثبت نشده است" />
|
|
) : null}
|
|
{!overviewLoading && !overviewError && overview ? (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="mb-1 text-xs font-medium text-slate-500">خلاصه تکالیف</h3>
|
|
{overview.assignments.total === 0 ? (
|
|
<AdminEmptyState title="تکلیفی ثبت نشده است" />
|
|
) : (
|
|
<>
|
|
<AdminInfoRow label="کل" value={String(overview.assignments.total)} />
|
|
<AdminInfoRow
|
|
label="تکمیلشده"
|
|
value={String(overview.assignments.completed)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="در حال انجام"
|
|
value={String(overview.assignments.inProgress)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="شروعنشده"
|
|
value={String(overview.assignments.notStarted)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="دارای گزارش (REPORTED)"
|
|
value={String(overview.assignments.reported)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="درصد تکمیل تکالیف"
|
|
value={formatAdminPercent(overview.assignments.progressPercent)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="آخرین فعالیت تکالیف"
|
|
value={formatAdminDate(overview.assignments.lastActivityAt)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="mb-1 text-xs font-medium text-slate-500">مسیر آموزشی</h3>
|
|
<AdminInfoRow
|
|
label="مرحله فعلی"
|
|
value={
|
|
overview.learningProgress.currentStep
|
|
? STEP_LABELS[overview.learningProgress.currentStep] ??
|
|
overview.learningProgress.currentStep
|
|
: 'اطلاعاتی ثبت نشده است'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="جلسات تکمیلشده"
|
|
value={`${overview.learningProgress.completedLessons.toLocaleString('fa-IR')} از ${overview.learningProgress.totalLessons.toLocaleString('fa-IR')}`}
|
|
/>
|
|
<AdminInfoRow
|
|
label="درصد پیشرفت جلسات"
|
|
value={formatAdminPercent(overview.learningProgress.progressPercent)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="آخرین فعالیت مسیر"
|
|
value={formatAdminDate(overview.learningProgress.lastActivityAt)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="mb-1 text-xs font-medium text-slate-500">خلاصه آزمونها</h3>
|
|
<AdminInfoRow
|
|
label="وضعیت کلی"
|
|
value={
|
|
ASSESSMENT_OVERALL_LABELS[overview.assessments.overallStatus] ??
|
|
overview.assessments.overallStatus
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="پیشآزمون"
|
|
value={
|
|
overview.assessments.hasPretest
|
|
? formatAdminDate(overview.assessments.pretestCompletedAt)
|
|
: 'ثبت نشده'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="پسآزمون"
|
|
value={
|
|
overview.assessments.hasPosttest
|
|
? formatAdminDate(overview.assessments.posttestCompletedAt)
|
|
: 'ثبت نشده'
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="mb-1 text-xs font-medium text-slate-500">فعالیتهای اخیر</h3>
|
|
{overview.recentActivities.length === 0 ? (
|
|
<AdminEmptyState title="فعالیتی ثبت نشده است" />
|
|
) : (
|
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
|
|
{overview.recentActivities.map((activity, index) => (
|
|
<li
|
|
key={`${activity.type}-${activity.relatedId ?? index}`}
|
|
className="py-2"
|
|
>
|
|
<p className="text-sm text-slate-900 dark:text-slate-50">
|
|
{activity.title}
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
{ACTIVITY_TYPE_LABELS[activity.type] ?? activity.type} ·{' '}
|
|
{formatAdminDate(activity.occurredAt)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</AdminSection>
|
|
|
|
<AdminSection
|
|
title="برنامهٔ آموزشی و پیشرفت"
|
|
actions={
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
disabled={exportLoading || assignmentsLoading || Boolean(assignmentsError)}
|
|
onClick={() => void exportAssignments()}
|
|
aria-busy={exportLoading}
|
|
className="text-xs"
|
|
>
|
|
{exportLoading ? 'در حال آمادهسازی…' : 'خروجی Excel'}
|
|
</AdminSecondaryButton>
|
|
}
|
|
>
|
|
<div className="mb-3 space-y-2 rounded-lg border border-slate-100 p-3 dark:border-slate-700">
|
|
<p className="text-xs font-medium text-slate-500">فیلتر تکالیف</p>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<Input
|
|
value={assignmentSearchInput}
|
|
onChange={(event) => setAssignmentSearchInput(event.target.value)}
|
|
placeholder="جستوجو در عنوان، کلید یا جلسه…"
|
|
className="h-9 text-sm"
|
|
maxLength={100}
|
|
aria-label="جستوجوی تکالیف"
|
|
/>
|
|
<select
|
|
value={assignmentStatusFilter}
|
|
onChange={(event) => setAssignmentStatusFilter(event.target.value)}
|
|
className={filterSelectClass}
|
|
aria-label="فیلتر وضعیت تکلیف"
|
|
>
|
|
<option value="">همه وضعیتها</option>
|
|
{ASSIGNMENT_STATUS_OPTIONS.map((status) => (
|
|
<option key={status} value={status}>
|
|
{ASSIGNMENT_STATUS_LABELS[status]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={assignmentTypeFilter}
|
|
onChange={(event) => setAssignmentTypeFilter(event.target.value)}
|
|
className={filterSelectClass}
|
|
aria-label="فیلتر نوع تکلیف"
|
|
>
|
|
<option value="">همه انواع</option>
|
|
{ASSIGNMENT_TYPE_OPTIONS.map((type) => (
|
|
<option key={type} value={type}>
|
|
{ASSIGNMENT_TYPE_LABELS[type]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={assignmentSortBy}
|
|
onChange={(event) =>
|
|
setAssignmentSortBy(
|
|
event.target.value as AdminAssignmentsQuery['sortBy'],
|
|
)
|
|
}
|
|
className={cn(filterSelectClass, 'flex-1')}
|
|
aria-label="مرتبسازی تکالیف"
|
|
>
|
|
<option value="sessionOrder">ترتیب جلسه</option>
|
|
<option value="activatedAt">تاریخ تخصیص</option>
|
|
<option value="completedAt">تاریخ تکمیل</option>
|
|
<option value="status">وضعیت</option>
|
|
</select>
|
|
<select
|
|
value={assignmentSortDir}
|
|
onChange={(event) =>
|
|
setAssignmentSortDir(
|
|
event.target.value as AdminAssignmentsQuery['sortDir'],
|
|
)
|
|
}
|
|
className={cn(filterSelectClass, 'w-24')}
|
|
aria-label="جهت مرتبسازی تکالیف"
|
|
>
|
|
<option value="asc">صعودی</option>
|
|
<option value="desc">نزولی</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{hasAssignmentFilters ||
|
|
assignmentSortBy !== 'sessionOrder' ||
|
|
assignmentSortDir !== 'asc' ? (
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
onClick={clearAssignmentFilters}
|
|
className="text-xs"
|
|
>
|
|
پاککردن فیلترها
|
|
</AdminSecondaryButton>
|
|
) : null}
|
|
</div>
|
|
|
|
{exportError ? (
|
|
<AdminErrorState message={exportError} className="mb-2" />
|
|
) : null}
|
|
|
|
{assignmentsLoading ? (
|
|
<AdminLoadingBlock label="در حال بارگذاری برنامهٔ آموزشی…" rows={3} />
|
|
) : null}
|
|
|
|
{!assignmentsLoading && assignmentsError ? (
|
|
<AdminErrorState
|
|
message={assignmentsError}
|
|
onRetry={() => void loadAssignments()}
|
|
/>
|
|
) : null}
|
|
|
|
{!assignmentsLoading && !assignmentsError && learningProgress ? (
|
|
<div className="space-y-1 pb-3">
|
|
<h3 className="text-xs font-medium text-slate-500">خلاصهٔ پیشرفت</h3>
|
|
<AdminInfoRow
|
|
label="مرحلهٔ فعلی"
|
|
value={
|
|
learningProgress.currentStep
|
|
? STEP_LABELS[learningProgress.currentStep] ??
|
|
learningProgress.currentStep
|
|
: 'بدون LearningJourney'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="وضعیت آموزش"
|
|
value={
|
|
learningProgress.trainingCompleted
|
|
? 'تکمیلشده'
|
|
: learningProgress.trainingStarted
|
|
? 'در حال انجام'
|
|
: 'شروعنشده'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="تکمیل دوره"
|
|
value={learningProgress.courseCompleted ? 'بله' : 'خیر'}
|
|
/>
|
|
<AdminInfoRow
|
|
label="دروس تکمیلشده"
|
|
value={`${learningProgress.completedLessons.toLocaleString('fa-IR')} از ${learningProgress.totalLessons.toLocaleString('fa-IR')}`}
|
|
/>
|
|
<AdminInfoRow
|
|
label="درصد پیشرفت جلسات"
|
|
value={formatAdminPercent(learningProgress.progressPercent)}
|
|
/>
|
|
{assignmentSummary ? (
|
|
<>
|
|
<AdminInfoRow
|
|
label="تکالیف"
|
|
value={`${assignmentSummary.completed.toLocaleString('fa-IR')} تکمیل از ${assignmentSummary.total.toLocaleString('fa-IR')}`}
|
|
/>
|
|
<AdminInfoRow
|
|
label="در حال انجام / شروعنشده"
|
|
value={`${assignmentSummary.inProgress.toLocaleString('fa-IR')} / ${assignmentSummary.pending.toLocaleString('fa-IR')}`}
|
|
/>
|
|
<AdminInfoRow
|
|
label="درصد تکمیل تکالیف"
|
|
value={formatAdminPercent(assignmentSummary.progressPercent)}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!assignmentsLoading && !assignmentsError && assignments.length === 0 ? (
|
|
<AdminEmptyState
|
|
title={
|
|
hasAssignmentFilters
|
|
? 'با این فیلترها تکلیفی یافت نشد'
|
|
: 'تکلیفی برای این کاربر ثبت نشده است'
|
|
}
|
|
description={
|
|
hasAssignmentFilters
|
|
? 'فیلترها را تغییر دهید یا پاک کنید تا همه تکالیف را ببینید.'
|
|
: undefined
|
|
}
|
|
action={
|
|
hasAssignmentFilters ? (
|
|
<AdminSecondaryButton type="button" onClick={clearAssignmentFilters}>
|
|
پاککردن فیلترها
|
|
</AdminSecondaryButton>
|
|
) : undefined
|
|
}
|
|
/>
|
|
) : null}
|
|
|
|
{!assignmentsLoading && !assignmentsError && assignments.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-medium text-slate-500">فهرست تکالیف</h3>
|
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
|
|
{assignments.map((item) => (
|
|
<li
|
|
key={item.assignmentId}
|
|
className="flex flex-wrap items-start justify-between gap-2 py-3"
|
|
>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-50">
|
|
{item.definitionTitle}
|
|
</p>
|
|
<AssignmentStatusBadge status={item.status} />
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
جلسه {item.sessionOrder.toLocaleString('fa-IR')}
|
|
{item.sessionTitle ? `: ${item.sessionTitle}` : ''} ·{' '}
|
|
{ASSIGNMENT_TYPE_LABELS[item.assignmentType] ?? item.assignmentType}
|
|
</p>
|
|
<div className="grid gap-1 text-xs text-slate-600 dark:text-slate-300 sm:grid-cols-2">
|
|
<span>تخصیص: {formatAdminDate(item.assignedAt)}</span>
|
|
<span>شروع: {formatAdminDate(item.startedAt)}</span>
|
|
<span>تکمیل: {formatAdminDate(item.completedAt)}</span>
|
|
<span>
|
|
پیشرفت:{' '}
|
|
{item.progressPercent != null
|
|
? formatAdminPercent(item.progressPercent)
|
|
: item.expectedDurationDays != null
|
|
? `${item.doneDayCount.toLocaleString('fa-IR')} / ${item.expectedDurationDays.toLocaleString('fa-IR')} روز`
|
|
: '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
disabled={assignmentDetailLoading}
|
|
onClick={() => void openAssignmentDetail(item.assignmentId)}
|
|
className="text-xs"
|
|
aria-label={`جزئیات تکلیف ${item.definitionTitle}`}
|
|
>
|
|
جزئیات
|
|
</AdminSecondaryButton>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
{assignmentDetailLoading ? (
|
|
<AdminLoadingBlock label="در حال بارگذاری جزئیات تکلیف…" rows={2} className="pt-3" />
|
|
) : null}
|
|
{assignmentDetailError ? (
|
|
<AdminErrorState message={assignmentDetailError} className="mt-3" />
|
|
) : null}
|
|
{selectedAssignment && !assignmentDetailLoading ? (
|
|
<div className="mt-3 space-y-2 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">
|
|
{selectedAssignment.assignment.definitionTitle}
|
|
</p>
|
|
<p className="break-all text-xs text-slate-500" dir="ltr">
|
|
{selectedAssignment.assignment.definitionKey} ·{' '}
|
|
{selectedAssignment.assignment.id}
|
|
</p>
|
|
</div>
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
className="text-xs"
|
|
onClick={() => {
|
|
setSelectedAssignment(null);
|
|
setAssignmentDetailError(null);
|
|
}}
|
|
aria-label="بستن جزئیات تکلیف"
|
|
>
|
|
بستن
|
|
</AdminSecondaryButton>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
<AssignmentStatusBadge status={selectedAssignment.assignment.status} />
|
|
<AdminStatusPill
|
|
ok
|
|
label={
|
|
ASSIGNMENT_TYPE_LABELS[selectedAssignment.assignment.assignmentType] ??
|
|
selectedAssignment.assignment.assignmentType
|
|
}
|
|
/>
|
|
</div>
|
|
<AdminInfoRow
|
|
label="جلسه"
|
|
value={`#${selectedAssignment.session.order.toLocaleString('fa-IR')} — ${selectedAssignment.session.title}`}
|
|
/>
|
|
<AdminInfoRow
|
|
label="تخصیص"
|
|
value={formatAdminDate(selectedAssignment.assignment.assignedAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="شروع"
|
|
value={formatAdminDate(selectedAssignment.assignment.startedAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="تکمیل"
|
|
value={formatAdminDate(selectedAssignment.assignment.completedAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="آخرین فعالیت تکلیف"
|
|
value={formatAdminDate(selectedAssignment.assignment.lastActivityAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="روزهای انجامشده"
|
|
value={selectedAssignment.assignment.doneDayCount.toLocaleString('fa-IR')}
|
|
/>
|
|
<AdminInfoRow
|
|
label="مدت هدف (روز)"
|
|
value={
|
|
selectedAssignment.assignment.expectedDurationDays != null
|
|
? selectedAssignment.assignment.expectedDurationDays.toLocaleString(
|
|
'fa-IR',
|
|
)
|
|
: '—'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="پیشرفت تکلیف"
|
|
value={formatAdminPercent(selectedAssignment.assignment.progressPercent)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="مرحلهٔ کاربر"
|
|
value={
|
|
selectedAssignment.learningProgress.currentStep
|
|
? STEP_LABELS[selectedAssignment.learningProgress.currentStep] ??
|
|
selectedAssignment.learningProgress.currentStep
|
|
: '—'
|
|
}
|
|
/>
|
|
<AdminInfoRow
|
|
label="جلسات دوره"
|
|
value={`${selectedAssignment.learningProgress.completedLessons.toLocaleString('fa-IR')} از ${selectedAssignment.learningProgress.totalLessons.toLocaleString('fa-IR')} (${formatAdminPercent(selectedAssignment.learningProgress.progressPercent)})`}
|
|
/>
|
|
{selectedAssignment.activity ? (
|
|
<>
|
|
<AdminInfoRow
|
|
label="اولین فعالیت"
|
|
value={formatAdminDate(selectedAssignment.activity.firstActivityAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="آخرین فعالیت ثبتشده"
|
|
value={formatAdminDate(selectedAssignment.activity.lastActivityAt)}
|
|
/>
|
|
<AdminInfoRow
|
|
label="تعداد فعالیت تکمیلشده"
|
|
value={selectedAssignment.activity.completedActivityCount.toLocaleString(
|
|
'fa-IR',
|
|
)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<AdminEmptyState title="فعالیتی برای این تکلیف ثبت نشده است" />
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</AdminSection>
|
|
|
|
<section className="space-y-3">
|
|
<div className="flex gap-2" role="tablist" aria-label="انتخاب آزمون">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={assessmentTab === 'pre'}
|
|
onClick={() => setAssessmentTab('pre')}
|
|
className={cn(
|
|
'rounded-lg px-3 py-1.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
assessmentTab === 'pre'
|
|
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
|
: 'border border-slate-200 text-slate-700 dark:border-slate-600 dark:text-slate-200',
|
|
)}
|
|
>
|
|
پیشآزمون
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={assessmentTab === 'post'}
|
|
onClick={() => setAssessmentTab('post')}
|
|
className={cn(
|
|
'rounded-lg px-3 py-1.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
assessmentTab === 'post'
|
|
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
|
|
: 'border border-slate-200 text-slate-700 dark:border-slate-600 dark:text-slate-200',
|
|
)}
|
|
>
|
|
پسآزمون
|
|
</button>
|
|
</div>
|
|
|
|
{assessmentsLoading ? (
|
|
<AdminSection>
|
|
<AdminLoadingBlock label="در حال بارگذاری آزمونها…" rows={3} />
|
|
</AdminSection>
|
|
) : null}
|
|
|
|
{!assessmentsLoading && assessmentsError ? (
|
|
<AdminErrorState
|
|
message={assessmentsError}
|
|
onRetry={() => {
|
|
setAssessmentsLoading(true);
|
|
setAssessmentsError(null);
|
|
void fetchAdminUserAssessments(externalId)
|
|
.then((next) => setAssessments(next))
|
|
.catch((err) =>
|
|
setAssessmentsError(
|
|
getAdminErrorMessage(err, 'دریافت آزمونها ناموفق بود.'),
|
|
),
|
|
)
|
|
.finally(() => setAssessmentsLoading(false));
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{!assessmentsLoading && !assessmentsError ? (
|
|
<AssessmentPhasePanel
|
|
title={assessmentTab === 'pre' ? 'پیشآزمون (PCQ-24)' : 'پسآزمون (PCQ-24)'}
|
|
phase={
|
|
assessmentTab === 'pre'
|
|
? assessments?.pretest ?? null
|
|
: assessments?.posttest ?? null
|
|
}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
|
|
<AdminSection title="جلسات چت">
|
|
{chatsLoading ? (
|
|
<AdminLoadingBlock label="در حال بارگذاری جلسات…" rows={3} />
|
|
) : null}
|
|
{!chatsLoading && chatsError ? (
|
|
<AdminErrorState
|
|
message={chatsError}
|
|
onRetry={() => void loadChatSessions(chatPage || 1)}
|
|
/>
|
|
) : null}
|
|
{!chatsLoading && !chatsError && chatSessions.length === 0 ? (
|
|
<AdminEmptyState title="جلسهای برای این کاربر ثبت نشده است" />
|
|
) : null}
|
|
{!chatsLoading && !chatsError && chatSessions.length > 0 ? (
|
|
<>
|
|
<p className="text-xs text-slate-500">
|
|
{chatTotal.toLocaleString('fa-IR')} جلسه · صفحه{' '}
|
|
{chatPage.toLocaleString('fa-IR')}
|
|
{chatTotalPages > 0
|
|
? ` از ${chatTotalPages.toLocaleString('fa-IR')}`
|
|
: ''}
|
|
</p>
|
|
<ul className="divide-y divide-slate-100 dark:divide-slate-700">
|
|
{chatSessions.map((session) => (
|
|
<li
|
|
key={session.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 py-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-50">
|
|
{session.title}
|
|
</p>
|
|
<p className="text-xs text-slate-500">
|
|
{session.status === 'ended' ? 'پایانیافته' : 'باز'} ·{' '}
|
|
{session.messageCount.toLocaleString('fa-IR')} پیام · شروع:{' '}
|
|
{formatAdminDate(session.startedAt)} · فعالیت:{' '}
|
|
{formatAdminDate(session.lastActivityAt)}
|
|
</p>
|
|
</div>
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
onClick={() => void openChatSession(session.id)}
|
|
className="text-xs"
|
|
aria-label={`مشاهده جلسه ${session.title}`}
|
|
>
|
|
مشاهده
|
|
</AdminSecondaryButton>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="flex items-center justify-between gap-2 pt-1">
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
disabled={chatPage <= 1 || chatsLoading}
|
|
onClick={() => void loadChatSessions(chatPage - 1)}
|
|
className="text-xs"
|
|
aria-label="صفحه قبلی جلسات چت"
|
|
>
|
|
قبلی
|
|
</AdminSecondaryButton>
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
disabled={
|
|
chatTotalPages === 0 || chatPage >= chatTotalPages || chatsLoading
|
|
}
|
|
onClick={() => void loadChatSessions(chatPage + 1)}
|
|
className="text-xs"
|
|
aria-label="صفحه بعدی جلسات چت"
|
|
>
|
|
بعدی
|
|
</AdminSecondaryButton>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{chatDetailLoading ? (
|
|
<AdminLoadingBlock label="در حال بارگذاری پیامها…" rows={2} className="pt-3" />
|
|
) : null}
|
|
{chatDetailError ? (
|
|
<AdminErrorState message={chatDetailError} className="mt-3" />
|
|
) : null}
|
|
{selectedChat && !chatDetailLoading ? (
|
|
<div className="mt-3 space-y-3 rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-sm font-medium">{selectedChat.session.title}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{selectedChat.session.messageCount.toLocaleString('fa-IR')} پیام ·{' '}
|
|
{selectedChat.session.status === 'ended' ? 'پایانیافته' : 'باز'}
|
|
</p>
|
|
</div>
|
|
<AdminSecondaryButton
|
|
type="button"
|
|
className="text-xs"
|
|
onClick={() => {
|
|
setSelectedChat(null);
|
|
setChatDetailError(null);
|
|
}}
|
|
aria-label="بستن پیامهای جلسه"
|
|
>
|
|
بستن
|
|
</AdminSecondaryButton>
|
|
</div>
|
|
{selectedChat.messages.length === 0 ? (
|
|
<AdminEmptyState title="پیامی در این جلسه ثبت نشده است" />
|
|
) : (
|
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
|
{selectedChat.messages.map((message) => (
|
|
<ChatMessageBubble key={message.id} message={message} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</AdminSection>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</AdminPageShell>
|
|
);
|
|
}
|