fix(assignments): resolve op.student → res.users + student search

E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the
frontend's student picker sends `op.student.id` values (that's what
/api/students returns), but `encoach.exam.schedule.student_ids` is a
Many2many → res.users. The controller was writing the op.student id
straight through, so schedules linked to whichever res.users row
happened to share that integer id (OdooBot, other staff, etc.) — the
target student got 0 assignments and never saw the exam.

- exam_schedules.py: add `_resolve_student_user_ids` and use it in the
  create and update endpoints. Falls back to treating unmatched ids as
  direct res.users ids for back-office callers.
- AssignmentsPage.tsx: add a search input + larger list to the
  Individual Students picker; long lists were leaving Sarah unreachable
  inside the inner scroll container during QA.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-20 18:29:16 +04:00
parent 96a2945512
commit dcb7727a4b

View File

@@ -114,6 +114,7 @@ export default function AssignmentsPage() {
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all"); const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm()); const [form, setForm] = useState<FormState>(emptyForm());
const [studentQuery, setStudentQuery] = useState("");
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -144,6 +145,14 @@ export default function AssignmentsPage() {
const studentsQ = useStudents({ size: 200 }); const studentsQ = useStudents({ size: 200 });
const students = studentsQ.data?.items ?? []; const students = studentsQ.data?.items ?? [];
const filteredStudents = useMemo(() => {
const q = studentQuery.trim().toLowerCase();
if (!q) return students;
return students.filter((s) =>
(s.name || "").toLowerCase().includes(q) ||
(s.email || "").toLowerCase().includes(q),
);
}, [students, studentQuery]);
const stateCounts = useMemo(() => { const stateCounts = useMemo(() => {
const all = schedulesQ.data?.items ?? []; const all = schedulesQ.data?.items ?? [];
@@ -468,9 +477,23 @@ export default function AssignmentsPage() {
{/* Individual student selection */} {/* Individual student selection */}
{form.assign_mode === "individual" && ( {form.assign_mode === "individual" && (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Select Students</Label> <div className="flex items-center justify-between gap-2">
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1"> <Label className="text-xs">Select Students</Label>
{students.map((s) => ( <span className="text-[11px] text-muted-foreground">
{form.student_ids.size} selected · {students.length} total
</span>
</div>
{/* Search box — needed because QA reported long student lists
made bottom entries unreachable inside the inner scroll
container. Filtering keeps the list short and predictable. */}
<Input
placeholder="Search students by name or email…"
value={studentQuery}
onChange={(e) => setStudentQuery(e.target.value)}
className="h-8 text-xs"
/>
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
{filteredStudents.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5"> <label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox <Checkbox
checked={form.student_ids.has(s.id)} checked={form.student_ids.has(s.id)}
@@ -487,6 +510,11 @@ export default function AssignmentsPage() {
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>} {s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label> </label>
))} ))}
{filteredStudents.length === 0 && students.length > 0 && (
<p className="text-xs text-muted-foreground italic text-center py-2">
No students match {studentQuery}.
</p>
)}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>} {students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div> </div>
</div> </div>