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>
461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { AssignmentFormFields } from '@/components/assignments/AssignmentFormFields';
|
|
import {
|
|
assignmentDayStatusLabel,
|
|
assignmentStatusLabel,
|
|
assignmentTypeLabel,
|
|
createAssignmentEntry,
|
|
fetchAssignment,
|
|
isMultiDayAssignment,
|
|
saveAssignmentDraft,
|
|
startAssignment,
|
|
submitAssignmentReport,
|
|
updateAssignmentEntry,
|
|
type AssignmentDayStatus,
|
|
type AssignmentFormField,
|
|
type AssignmentReportStatus,
|
|
type UserAssignment,
|
|
} from '@/services/assignmentService';
|
|
import { useToast } from '@/components/ui/use-toast';
|
|
import { getFetchErrorMessage } from '@/lib/fetchError';
|
|
import { PageSkeleton } from '@/components/skeletons';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import { ArrowRight, Check, Loader2 } from 'lucide-react';
|
|
|
|
function emptyDraftFromSchema(schema: AssignmentFormField[] | null): Record<string, unknown> {
|
|
if (!schema) return { text: '' };
|
|
const draft: Record<string, unknown> = {};
|
|
for (const field of schema) {
|
|
if (field.type === 'list') {
|
|
draft[field.key] = [{}];
|
|
} else if (field.type === 'checkbox') {
|
|
draft[field.key] = false;
|
|
} else {
|
|
draft[field.key] = '';
|
|
}
|
|
}
|
|
return draft;
|
|
}
|
|
|
|
function progressLabel(assignment: UserAssignment): string | null {
|
|
const target = assignment.definition.expectedDurationDays;
|
|
if (!target) return `${assignment.doneDayCount} روز انجامشده`;
|
|
return `${assignment.doneDayCount} روز از ${target} روز هدف`;
|
|
}
|
|
|
|
/**
|
|
* Assignment detail — one-time vs multi-day flows diverge deliberately.
|
|
* Status is system-driven; experience notes are optional and soft.
|
|
*/
|
|
export default function AssignmentDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { effectiveUserId, guestSessionHydrated, isGuest } = useAuth();
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
|
|
const [assignment, setAssignment] = useState<UserAssignment | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
|
const [entryData, setEntryData] = useState<Record<string, unknown>>({});
|
|
const [entryNote, setEntryNote] = useState('');
|
|
const [dayStatus, setDayStatus] = useState<AssignmentDayStatus>('done');
|
|
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
|
const [experienceNote, setExperienceNote] = useState('');
|
|
const [experienceStatus, setExperienceStatus] =
|
|
useState<AssignmentReportStatus>('difficult');
|
|
|
|
const load = useCallback(async () => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
try {
|
|
const row = await fetchAssignment(id);
|
|
setAssignment(row);
|
|
const schema = row.definition.formSchema;
|
|
setDraft(
|
|
(row.draftData as Record<string, unknown> | null) ?? emptyDraftFromSchema(schema),
|
|
);
|
|
const today = row.serverToday;
|
|
const todayEntry = row.dailyEntries?.find((e) => e.entryDate === today);
|
|
if (todayEntry) {
|
|
setEditingEntryId(todayEntry.id);
|
|
setEntryData(todayEntry.responseData);
|
|
setEntryNote(todayEntry.note ?? '');
|
|
setDayStatus(todayEntry.dayStatus);
|
|
} else {
|
|
setEditingEntryId(null);
|
|
setEntryData(emptyDraftFromSchema(schema));
|
|
setEntryNote('');
|
|
setDayStatus('done');
|
|
}
|
|
} catch (err) {
|
|
toast({
|
|
title: 'خطا',
|
|
description: getFetchErrorMessage(err, 'بارگذاری تکلیف ناموفق بود'),
|
|
variant: 'destructive',
|
|
});
|
|
navigate('/assignments', { replace: true });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id, navigate, toast]);
|
|
|
|
useEffect(() => {
|
|
if (!effectiveUserId || isGuest) return;
|
|
void load();
|
|
}, [effectiveUserId, isGuest, load]);
|
|
|
|
if (!guestSessionHydrated || loading) {
|
|
return <PageSkeleton />;
|
|
}
|
|
|
|
if (!effectiveUserId) {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
if (isGuest || !assignment) {
|
|
return <Navigate to="/assignments" replace />;
|
|
}
|
|
|
|
const isMultiDay = isMultiDayAssignment(assignment.definition.assignmentType);
|
|
const isOneTime = assignment.definition.assignmentType === 'ONE_TIME';
|
|
const schema = assignment.definition.formSchema;
|
|
const completed = assignment.status === 'COMPLETED';
|
|
const serverToday = assignment.serverToday;
|
|
|
|
const updateDraftField = (key: string, value: unknown) => {
|
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const updateEntryField = (key: string, value: unknown) => {
|
|
setEntryData((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleSaveDraft = async (completeIfValid: boolean) => {
|
|
setSaving(true);
|
|
try {
|
|
await startAssignment(assignment.id);
|
|
const updated = await saveAssignmentDraft(assignment.id, draft, { completeIfValid });
|
|
setAssignment(updated);
|
|
toast({
|
|
title: updated.status === 'COMPLETED' ? 'پاسخ ثبت و تکلیف تکمیل شد' : 'پیشنویس ذخیره شد',
|
|
description:
|
|
updated.status === 'COMPLETED'
|
|
? undefined
|
|
: completeIfValid
|
|
? undefined
|
|
: 'برای تکمیل، فیلدهای الزامی را پر کن و «ثبت پاسخ» را بزن.',
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: 'خطا',
|
|
description: getFetchErrorMessage(err, 'ذخیره پاسخ ناموفق بود'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveEntry = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await startAssignment(assignment.id);
|
|
const payload = {
|
|
dayStatus,
|
|
responseData: dayStatus === 'done' ? entryData : {},
|
|
note: entryNote || null,
|
|
};
|
|
if (editingEntryId) {
|
|
await updateAssignmentEntry(assignment.id, editingEntryId, payload);
|
|
} else {
|
|
await createAssignmentEntry(assignment.id, {
|
|
...payload,
|
|
entryDate: serverToday,
|
|
});
|
|
}
|
|
await load();
|
|
toast({ title: 'ثبت امروز ذخیره شد' });
|
|
} catch (err) {
|
|
toast({
|
|
title: 'خطا',
|
|
description: getFetchErrorMessage(err, 'ثبت روزانه ناموفق بود'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleExperienceNote = async () => {
|
|
const text = experienceNote.trim();
|
|
if (text.length < 3) {
|
|
toast({
|
|
title: 'یادداشت کوتاه است',
|
|
description: 'اگر میخواهی مانعی را ثبت کنی، چند کلمه بنویس.',
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const result = await submitAssignmentReport(assignment.id, text, experienceStatus);
|
|
setAssignment(result.assignment);
|
|
setExperienceNote('');
|
|
toast({ title: 'یادداشت تجربه ثبت شد' });
|
|
} catch (err) {
|
|
toast({
|
|
title: 'خطا',
|
|
description: getFetchErrorMessage(err, 'ثبت یادداشت ناموفق بود'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const dayOptions: Array<{ value: AssignmentDayStatus; label: string }> = [
|
|
{ value: 'done', label: 'بله، انجام دادم' },
|
|
{ value: 'skipped', label: 'امروز انجام ندادم' },
|
|
{ value: 'difficult', label: 'امروز برایم دشوار بود' },
|
|
];
|
|
|
|
return (
|
|
<div className="h-full min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-slate-50 px-4 py-6 dark:bg-slate-900">
|
|
<div className="mx-auto w-full max-w-md space-y-5">
|
|
<div>
|
|
<Link
|
|
to="/assignments"
|
|
className="mb-3 inline-flex items-center gap-1 text-sm text-sky-700 dark:text-sky-300"
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
بازگشت به تکالیف
|
|
</Link>
|
|
<p className="text-[11px] font-medium text-sky-700 dark:text-sky-300">
|
|
جلسه {assignment.definition.sessionOrder}
|
|
</p>
|
|
<h1 className="text-xl font-bold text-foreground">{assignment.definition.title}</h1>
|
|
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
|
<span className="rounded-md bg-slate-100 px-2 py-0.5 dark:bg-slate-700">
|
|
{assignmentTypeLabel(assignment.definition.assignmentType)}
|
|
</span>
|
|
<span className="rounded-md bg-sky-50 px-2 py-0.5 text-sky-800 dark:bg-sky-950 dark:text-sky-200">
|
|
{assignmentStatusLabel(assignment.status)}
|
|
</span>
|
|
{isMultiDay && <span>{progressLabel(assignment)}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<section className="rounded-xl border border-sky-200/80 bg-sky-50/70 p-4 dark:border-sky-900 dark:bg-sky-950/40">
|
|
<h2 className="mb-2 text-sm font-semibold text-foreground">راهنمای تمرین</h2>
|
|
<p className="whitespace-pre-wrap text-sm leading-7 text-body">
|
|
{assignment.definition.instructions}
|
|
</p>
|
|
</section>
|
|
|
|
{completed && isOneTime && (
|
|
<section className="space-y-3 rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 dark:border-emerald-900 dark:bg-emerald-950/30">
|
|
<h2 className="text-sm font-semibold text-foreground">پاسخ ثبتشده</h2>
|
|
<AssignmentFormFields fields={schema} values={draft} onChange={() => {}} disabled />
|
|
</section>
|
|
)}
|
|
|
|
{isOneTime && !completed && (
|
|
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
|
<h2 className="text-sm font-semibold text-foreground">پاسخ تمرین</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
فیلدهای ستارهدار لازماند. میتوانی پیشنویس ذخیره کنی و بعداً ادامه بدهی.
|
|
</p>
|
|
<AssignmentFormFields fields={schema} values={draft} onChange={updateDraftField} />
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void handleSaveDraft(false)}
|
|
disabled={saving}
|
|
className="w-full"
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ذخیره پیشنویس'}
|
|
</Button>
|
|
<Button onClick={() => void handleSaveDraft(true)} disabled={saving} className="w-full">
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ثبت پاسخ و تکمیل'}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{isMultiDay && (
|
|
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-foreground">پیشرفت کلی</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">{progressLabel(assignment)}</p>
|
|
</div>
|
|
{assignment.definition.minValidDays != null ? (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
تکمیل پس از {assignment.definition.minValidDays} روز انجامشده
|
|
</p>
|
|
) : (
|
|
<p className="max-w-[12rem] text-left text-[11px] leading-4 text-muted-foreground">
|
|
تکمیل خودکار فعلاً فعال نیست
|
|
</p>
|
|
)}
|
|
</div>
|
|
{assignment.completionBlockReason && !completed ? (
|
|
<p className="text-xs text-amber-800 dark:text-amber-200">
|
|
{assignment.completionBlockReason}
|
|
</p>
|
|
) : null}
|
|
{completed ? (
|
|
<p className="text-sm text-emerald-700 dark:text-emerald-300">این تمرین تکمیل شده است.</p>
|
|
) : null}
|
|
</section>
|
|
)}
|
|
|
|
{isMultiDay && !completed && (
|
|
<section className="space-y-3 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
|
|
<h2 className="text-sm font-semibold text-foreground">
|
|
ثبت امروز ({serverToday})
|
|
{editingEntryId ? ' — ویرایش' : ''}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">آیا امروز این تمرین را انجام دادی؟</p>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{dayOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => setDayStatus(opt.value)}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg border px-3 py-2 text-right text-sm transition-colors',
|
|
dayStatus === opt.value
|
|
? 'border-sky-500 bg-sky-50 dark:border-sky-400 dark:bg-sky-950'
|
|
: 'border-slate-200 dark:border-slate-600',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'inline-flex h-4 w-4 items-center justify-center rounded border',
|
|
dayStatus === opt.value
|
|
? 'border-sky-600 bg-sky-600 text-white'
|
|
: 'border-slate-300 dark:border-slate-500',
|
|
)}
|
|
>
|
|
{dayStatus === opt.value ? <Check className="h-2.5 w-2.5" /> : null}
|
|
</span>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{dayStatus === 'done' ? (
|
|
<>
|
|
<AssignmentFormFields
|
|
fields={schema}
|
|
values={entryData}
|
|
onChange={updateEntryField}
|
|
/>
|
|
<textarea
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
|
rows={2}
|
|
dir="rtl"
|
|
placeholder="یادداشت روزانه (اختیاری)"
|
|
value={entryNote}
|
|
onChange={(e) => setEntryNote(e.target.value)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<textarea
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
|
rows={2}
|
|
dir="rtl"
|
|
placeholder="اگر مایلید، دلیل یا مانع را کوتاه بنویسید (اختیاری)"
|
|
value={entryNote}
|
|
onChange={(e) => setEntryNote(e.target.value)}
|
|
/>
|
|
)}
|
|
|
|
<Button onClick={() => void handleSaveEntry()} disabled={saving} className="w-full">
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'ذخیره ثبت امروز'}
|
|
</Button>
|
|
|
|
{(assignment.dailyEntries?.length ?? 0) > 0 && (
|
|
<div className="space-y-2 border-t border-slate-100 pt-3 dark:border-slate-700">
|
|
<h3 className="text-xs font-medium text-muted-foreground">تاریخچه روزها</h3>
|
|
{assignment.dailyEntries?.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
className="block w-full rounded-lg border border-slate-100 px-3 py-2 text-right text-xs hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-900"
|
|
onClick={() => {
|
|
setEditingEntryId(entry.id);
|
|
setEntryData(entry.responseData);
|
|
setEntryNote(entry.note ?? '');
|
|
setDayStatus(entry.dayStatus);
|
|
}}
|
|
>
|
|
<span className="font-medium text-foreground">{entry.entryDate}</span>
|
|
<span className="mt-0.5 block text-muted-foreground">
|
|
{assignmentDayStatusLabel(entry.dayStatus)}
|
|
{entry.note ? ` · ${entry.note}` : ''}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{!completed && (
|
|
<section className="space-y-3 rounded-xl border border-dashed border-slate-200 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-800/60">
|
|
<h2 className="text-sm font-semibold text-foreground">یادداشت تجربه (اختیاری)</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
اگر انجام تمرین برایت دشوار بود یا میخواهی مانعی را ثبت کنی — جدا از پاسخ اصلی تمرین.
|
|
</p>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{(
|
|
[
|
|
{ value: 'difficult' as const, label: 'انجام آن برایم دشوار بود' },
|
|
{ value: 'in_progress' as const, label: 'هنوز درگیر تمرین هستم' },
|
|
{ value: 'not_started' as const, label: 'هنوز شروع نکردهام' },
|
|
] as const
|
|
).map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => setExperienceStatus(opt.value)}
|
|
className={cn(
|
|
'rounded-lg border px-3 py-2 text-right text-sm',
|
|
experienceStatus === opt.value
|
|
? 'border-slate-400 bg-slate-50 dark:border-slate-500 dark:bg-slate-900'
|
|
: 'border-slate-200 dark:border-slate-600',
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<textarea
|
|
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-900"
|
|
rows={2}
|
|
dir="rtl"
|
|
value={experienceNote}
|
|
onChange={(e) => setExperienceNote(e.target.value)}
|
|
placeholder="توضیح کوتاه (اختیاری اما توصیهشده)"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => void handleExperienceNote()}
|
|
disabled={saving}
|
|
className="w-full"
|
|
>
|
|
ثبت یادداشت تجربه
|
|
</Button>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|