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>
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
import { api, BackendError, clearSessionToken, getSessionToken } from '@/services/backend';
|
||||
|
||||
export type AdminStats = {
|
||||
totalUsers: number;
|
||||
guestUsers: number;
|
||||
registeredUsers: number;
|
||||
pretestCompletedUsers: number;
|
||||
posttestCompletedUsers: number;
|
||||
trainingInProgressUsers: number;
|
||||
trainingCompletedUsers: number;
|
||||
courseCompletedUsers: number;
|
||||
totalAssessmentAttempts: number;
|
||||
totalChatSessions: number;
|
||||
totalChatMessages: number;
|
||||
programSessionCount: number;
|
||||
};
|
||||
|
||||
export type AdminUserListItem = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: 'user' | 'admin';
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
name: string | null;
|
||||
age: number | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string | null;
|
||||
currentStep: string | null;
|
||||
pretestCompleted: boolean;
|
||||
posttestCompleted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
completedSessionCount: number;
|
||||
programSessionCount: number;
|
||||
};
|
||||
|
||||
export type AdminUserListResult = {
|
||||
items: AdminUserListItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminUsersQuery = {
|
||||
q?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'createdAt' | 'role' | 'externalId' | 'lastActivityAt';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
function toQueryString(params: AdminUsersQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.q) search.set('q', params.q);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminStats(): Promise<AdminStats> {
|
||||
return api.get<AdminStats>('/admin/stats');
|
||||
}
|
||||
|
||||
export function fetchAdminUsers(query: AdminUsersQuery = {}): Promise<AdminUserListResult> {
|
||||
return api.get<AdminUserListResult>(`/admin/users${toQueryString(query)}`);
|
||||
}
|
||||
|
||||
export type AdminAssignmentSummary = {
|
||||
total: number;
|
||||
notStarted: number;
|
||||
opened: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
};
|
||||
|
||||
export type AdminUserDetail = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: 'user' | 'admin';
|
||||
isGuest: boolean;
|
||||
profileComplete: boolean;
|
||||
name: string | null;
|
||||
age: number | null;
|
||||
createdAt: string;
|
||||
lastActivityAt: string | null;
|
||||
currentStep: string | null;
|
||||
permissions: {
|
||||
canTakePreTest: boolean;
|
||||
canOpenTraining: boolean;
|
||||
canTakePostTest: boolean;
|
||||
isCompleted: boolean;
|
||||
} | null;
|
||||
pretestCompleted: boolean;
|
||||
posttestCompleted: boolean;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
completedSessionCount: number;
|
||||
programSessionCount: number;
|
||||
preTestCompletedAt: string | null;
|
||||
trainingStartedAt: string | null;
|
||||
trainingCompletedAt: string | null;
|
||||
postTestCompletedAt: string | null;
|
||||
hasPretestAttempt: boolean;
|
||||
hasPosttestAttempt: boolean;
|
||||
chatSessionCount: number;
|
||||
assignments: AdminAssignmentSummary;
|
||||
};
|
||||
|
||||
export function fetchAdminUserDetail(externalId: string): Promise<AdminUserDetail> {
|
||||
return api.get<AdminUserDetail>(`/admin/users/${encodeURIComponent(externalId)}`);
|
||||
}
|
||||
|
||||
export type AdminUserOverview = {
|
||||
user: { id: string; email: string | null };
|
||||
assignments: {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
notStarted: number;
|
||||
reported: number;
|
||||
opened: number;
|
||||
progressPercent: number | null;
|
||||
lastActivityAt: string | null;
|
||||
};
|
||||
learningProgress: {
|
||||
currentStep: string | null;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
lastActivityAt: string | null;
|
||||
};
|
||||
assessments: {
|
||||
completedCount: number;
|
||||
expectedCount: number;
|
||||
remainingCount: number;
|
||||
hasPretest: boolean;
|
||||
hasPosttest: boolean;
|
||||
pretestCompletedAt: string | null;
|
||||
posttestCompletedAt: string | null;
|
||||
lastAssessmentAt: string | null;
|
||||
overallStatus: 'none' | 'partial' | 'complete';
|
||||
};
|
||||
recentActivities: Array<{
|
||||
type:
|
||||
| 'session_completed'
|
||||
| 'assignment_activated'
|
||||
| 'assignment_completed'
|
||||
| 'assessment_submitted'
|
||||
| 'chat_started';
|
||||
title: string;
|
||||
occurredAt: string;
|
||||
relatedId: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function fetchAdminUserOverview(externalId: string): Promise<AdminUserOverview> {
|
||||
return api.get<AdminUserOverview>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/overview`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssessmentScores = {
|
||||
total: number | null;
|
||||
efficacy: number | null;
|
||||
hope: number | null;
|
||||
resilience: number | null;
|
||||
optimism: number | null;
|
||||
};
|
||||
|
||||
export type AdminAssessmentAnswer = {
|
||||
questionId: string;
|
||||
questionNumber: number;
|
||||
rawValue: number;
|
||||
points: number;
|
||||
dimension: string;
|
||||
isReverseScored: boolean;
|
||||
};
|
||||
|
||||
export type AdminAssessmentPhaseView = {
|
||||
exists: boolean;
|
||||
phase: 'pre' | 'post';
|
||||
attemptId: string | null;
|
||||
status: string | null;
|
||||
submittedAt: string | null;
|
||||
scoredAt: string | null;
|
||||
scores: AdminAssessmentScores | null;
|
||||
answerCount: number;
|
||||
expectedAnswerCount: number;
|
||||
answers: AdminAssessmentAnswer[];
|
||||
};
|
||||
|
||||
export type AdminUserAssessments = {
|
||||
user: { id: string; email: string | null };
|
||||
pretest: AdminAssessmentPhaseView;
|
||||
posttest: AdminAssessmentPhaseView;
|
||||
};
|
||||
|
||||
export function fetchAdminUserAssessments(externalId: string): Promise<AdminUserAssessments> {
|
||||
return api.get<AdminUserAssessments>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assessments`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminChatSessionListItem = {
|
||||
id: string;
|
||||
sessionOrder: number;
|
||||
title: string;
|
||||
status: 'open' | 'ended';
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt: string;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type AdminChatSessionsListResult = {
|
||||
user: { id: string; email: string | null };
|
||||
items: AdminChatSessionListItem[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminChatMessageView = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt: string;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
export type AdminChatSessionDetail = {
|
||||
user: { id: string; email: string | null };
|
||||
session: AdminChatSessionListItem;
|
||||
messages: AdminChatMessageView[];
|
||||
};
|
||||
|
||||
export type AdminChatSessionsQuery = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: 'startedAt' | 'updatedAt' | 'sessionOrder' | 'createdAt';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
function chatSessionsQueryString(params: AdminChatSessionsQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminUserChatSessions(
|
||||
externalId: string,
|
||||
query: AdminChatSessionsQuery = {},
|
||||
): Promise<AdminChatSessionsListResult> {
|
||||
return api.get<AdminChatSessionsListResult>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions${chatSessionsQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAdminUserChatSessionDetail(
|
||||
externalId: string,
|
||||
sessionId: string,
|
||||
): Promise<AdminChatSessionDetail> {
|
||||
return api.get<AdminChatSessionDetail>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssignmentListItem = {
|
||||
assignmentId: string;
|
||||
definitionId: string;
|
||||
definitionKey: string;
|
||||
definitionTitle: string;
|
||||
assignmentType: string;
|
||||
programId: null;
|
||||
programTitle: null;
|
||||
sessionId: string | null;
|
||||
sessionOrder: number;
|
||||
sessionTitle: string | null;
|
||||
status: string;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
doneDayCount: number;
|
||||
expectedDurationDays: number | null;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminAssignmentsSummary = {
|
||||
total: number;
|
||||
notStarted: number;
|
||||
opened: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminLearningProgress = {
|
||||
currentStep: string | null;
|
||||
trainingStarted: boolean;
|
||||
trainingCompleted: boolean;
|
||||
courseCompleted: boolean;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
|
||||
export type AdminUserAssignmentsResult = {
|
||||
user: { id: string; email: string | null };
|
||||
learningProgress: AdminLearningProgress;
|
||||
summary: AdminAssignmentsSummary;
|
||||
items: AdminAssignmentListItem[];
|
||||
};
|
||||
|
||||
export type AdminAssignmentsQuery = {
|
||||
sortBy?: 'activatedAt' | 'completedAt' | 'status' | 'sessionOrder';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
status?: 'NOT_STARTED' | 'ACTIVE' | 'IN_PROGRESS' | 'REPORTED' | 'COMPLETED';
|
||||
assignmentType?: 'ONE_TIME' | 'DAILY' | 'ONGOING';
|
||||
search?: string;
|
||||
};
|
||||
|
||||
function assignmentsQueryString(params: AdminAssignmentsQuery): string {
|
||||
const search = new URLSearchParams();
|
||||
if (params.sortBy) search.set('sortBy', params.sortBy);
|
||||
if (params.sortDir) search.set('sortDir', params.sortDir);
|
||||
if (params.status) search.set('status', params.status);
|
||||
if (params.assignmentType) search.set('assignmentType', params.assignmentType);
|
||||
if (params.search) search.set('search', params.search);
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export function fetchAdminUserAssignments(
|
||||
externalId: string,
|
||||
query: AdminAssignmentsQuery = {},
|
||||
): Promise<AdminUserAssignmentsResult> {
|
||||
return api.get<AdminUserAssignmentsResult>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assignments${assignmentsQueryString(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminAssignmentDetail = {
|
||||
user: { id: string; email: string | null };
|
||||
assignment: {
|
||||
id: string;
|
||||
definitionId: string;
|
||||
definitionKey: string;
|
||||
definitionTitle: string;
|
||||
assignmentType: string;
|
||||
status: string;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
doneDayCount: number;
|
||||
expectedDurationDays: number | null;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
session: {
|
||||
id: string | null;
|
||||
order: number;
|
||||
title: string;
|
||||
};
|
||||
learningProgress: {
|
||||
currentStep: string | null;
|
||||
totalLessons: number;
|
||||
completedLessons: number;
|
||||
progressPercent: number | null;
|
||||
};
|
||||
activity: {
|
||||
firstActivityAt: string | null;
|
||||
lastActivityAt: string | null;
|
||||
completedActivityCount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function fetchAdminUserAssignmentDetail(
|
||||
externalId: string,
|
||||
assignmentId: string,
|
||||
): Promise<AdminAssignmentDetail> {
|
||||
return api.get<AdminAssignmentDetail>(
|
||||
`/admin/users/${encodeURIComponent(externalId)}/assignments/${encodeURIComponent(assignmentId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
if (!plain?.[1]) return null;
|
||||
const name = plain[1].trim();
|
||||
if (!name || /[\\/\n\r"]/.test(name)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = 'noopener';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary XLSX download — bypasses JSON api.get helper.
|
||||
*/
|
||||
export async function downloadAdminUserAssignmentsExport(
|
||||
externalId: string,
|
||||
query: AdminAssignmentsQuery = {},
|
||||
): Promise<void> {
|
||||
const path = `/admin/users/${encodeURIComponent(externalId)}/assignments/export${assignmentsQueryString(query)}`;
|
||||
const baseUrl = (import.meta.env.VITE_BACKEND_URL || '/api/backend').replace(/\/$/, '');
|
||||
const token = getSessionToken();
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, { method: 'GET', headers });
|
||||
} catch {
|
||||
throw new BackendError(
|
||||
0,
|
||||
'NETWORK_ERROR',
|
||||
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let code = 'UNKNOWN';
|
||||
let message = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
error?: { code?: string; message?: string };
|
||||
};
|
||||
code = body.error?.code ?? code;
|
||||
message = body.error?.message ?? message;
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
if (response.status === 401) {
|
||||
clearSessionToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
|
||||
}
|
||||
}
|
||||
throw new BackendError(response.status, code, message);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const fromHeader = parseContentDispositionFilename(
|
||||
response.headers.get('Content-Disposition'),
|
||||
);
|
||||
const safeFallback = `noya-assignments-${externalId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64) || 'user'}.xlsx`;
|
||||
triggerBrowserDownload(blob, fromHeader ?? safeFallback);
|
||||
}
|
||||
Reference in New Issue
Block a user