Files
pwa/src/pages/Admin/AdminUserDetailPage.tsx
T
ar.badieiandCursor c6243caa35 Add read-only Noya admin panel with assignments and UX polish.
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>
2026-09-21 08:00:24 +03:30

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>
);
}