Files
pwa/src/pages/Assignments/AssignmentDetailPage.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

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