This commit is contained in:
@@ -933,6 +933,7 @@ export interface ExamRoomItem {
|
|||||||
studentCount: number;
|
studentCount: number;
|
||||||
paperCount: number;
|
paperCount: number;
|
||||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||||
|
createdByStaffId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamPaperResource {
|
export interface ExamPaperResource {
|
||||||
@@ -985,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiExam = {
|
export const apiExam = {
|
||||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
|
||||||
const res = await staffFetch('/exam-rooms');
|
const q = opts?.mine ? '?mine=1' : '';
|
||||||
|
const res = await staffFetch(`/exam-rooms${q}`);
|
||||||
if (!res.ok) await parseError(res, 'Failed');
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
@@ -1031,6 +1033,14 @@ export const apiExam = {
|
|||||||
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
extend: async (id: number, minutes: number) => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${id}/extend`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ minutes }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Gia hạn thất bại');
|
||||||
|
return res.json() as Promise<{ ok: boolean; endTime: string; addedMinutes: number; displayStatus: string }>;
|
||||||
|
},
|
||||||
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
|
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
|
||||||
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [canPublish, setCanPublish] = useState(false);
|
const [canPublish, setCanPublish] = useState(false);
|
||||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||||
const [canCancel, setCanCancel] = useState(false);
|
const [canCancel, setCanCancel] = useState(false);
|
||||||
|
const [canExtend, setCanExtend] = useState(false);
|
||||||
|
const [extending, setExtending] = useState(false);
|
||||||
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
||||||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||||||
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
||||||
@@ -101,6 +103,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
|
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
|
||||||
const anyPaperSent = students.some((s) => s.paperSentAt);
|
const anyPaperSent = students.some((s) => s.paperSentAt);
|
||||||
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
|
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
|
||||||
|
const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled';
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -118,6 +121,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
setCanPublish(data.canPublish);
|
setCanPublish(data.canPublish);
|
||||||
setCanUnpublish(data.canUnpublish);
|
setCanUnpublish(data.canUnpublish);
|
||||||
setCanCancel(data.canCancel);
|
setCanCancel(data.canCancel);
|
||||||
|
setCanExtend(!!data.canExtend || data.displayStatus === 'active');
|
||||||
setPapers(data.papers);
|
setPapers(data.papers);
|
||||||
setStudents(data.students);
|
setStudents(data.students);
|
||||||
pushNav({
|
pushNav({
|
||||||
@@ -231,6 +235,20 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveExtendedEndTime = async () => {
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiExam.update(examId, { endTime: localInputToISO(end) });
|
||||||
|
setMsg('Đã gia hạn giờ kết thúc phòng thi');
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Lỗi');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addStudent = async (studentRkId: number) => {
|
const addStudent = async (studentRkId: number) => {
|
||||||
await apiExam.addStudents(examId, [studentRkId]);
|
await apiExam.addStudents(examId, [studentRkId]);
|
||||||
await load();
|
await load();
|
||||||
@@ -575,6 +593,21 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExtend = async (minutes: number) => {
|
||||||
|
if (!confirm(`Gia hạn thêm ${minutes} phút cho phòng thi đang diễn ra?`)) return;
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setExtending(true);
|
||||||
|
try {
|
||||||
|
const res = await apiExam.extend(examId, minutes);
|
||||||
|
setMsg(`Đã gia hạn +${minutes} phút — kết thúc lúc ${fmtTime(res.endTime)}`);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Gia hạn thất bại');
|
||||||
|
} finally {
|
||||||
|
setExtending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredStudents = students.filter((s) => {
|
const filteredStudents = students.filter((s) => {
|
||||||
const term = searchQuery.toLowerCase().trim();
|
const term = searchQuery.toLowerCase().trim();
|
||||||
if (!term) return true;
|
if (!term) return true;
|
||||||
@@ -667,6 +700,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
{canCancel && (
|
{canCancel && (
|
||||||
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
||||||
)}
|
)}
|
||||||
|
{canExtend && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600 }}>Gia hạn</span>
|
||||||
|
{[15, 30, 60].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={extending}
|
||||||
|
onClick={() => handleExtend(m)}
|
||||||
|
title={`Thêm ${m} phút vào giờ kết thúc`}
|
||||||
|
>
|
||||||
|
+{m}p
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{papers.length > 0 && (
|
{papers.length > 0 && (
|
||||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||||
📄 Xem đề
|
📄 Xem đề
|
||||||
@@ -713,7 +763,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
<div className="config-card config-card-flat">
|
<div className="config-card config-card-flat">
|
||||||
<div className="card-header-title">Thông tin phòng thi</div>
|
<div className="card-header-title">Thông tin phòng thi</div>
|
||||||
<p className="config-card-desc">
|
<p className="config-card-desc">
|
||||||
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'}
|
{editable
|
||||||
|
? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.'
|
||||||
|
: canExtend
|
||||||
|
? 'Phòng đang thi — có thể gia hạn giờ kết thúc bên dưới hoặc dùng nút +15p / +30p / +60p trên thanh trạng thái.'
|
||||||
|
: 'Phòng đã khóa chỉnh sửa.'}
|
||||||
</p>
|
</p>
|
||||||
<label className="login-field">
|
<label className="login-field">
|
||||||
<span>Tên phòng thi</span>
|
<span>Tên phòng thi</span>
|
||||||
@@ -754,13 +808,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="login-field">
|
<label className="login-field">
|
||||||
<span>Kết thúc</span>
|
<span>Kết thúc</span>
|
||||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} />
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
disabled={!editable && !canExtend}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{editable && (
|
{(editable || canExtend) && (
|
||||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
<button
|
||||||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={editable ? saveRoom : saveExtendedEndTime}
|
||||||
|
>
|
||||||
|
{saving ? 'Đang lưu...' : canExtend && !editable ? 'Lưu giờ kết thúc (gia hạn)' : 'Lưu thông tin'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canExtend && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||||
|
{[15, 30, 60].map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={extending}
|
||||||
|
onClick={() => handleExtend(m)}
|
||||||
|
>
|
||||||
|
+{m} phút
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1112,14 +1192,22 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
||||||
Nhắn tin
|
Nhắn tin
|
||||||
</button>
|
</button>
|
||||||
{prepEditable && (
|
{canRemoveStudents && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||||
style={{ marginLeft: '0.35rem' }}
|
style={{ marginLeft: '0.35rem' }}
|
||||||
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)}
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Xóa sinh viên "${s.fullName}" khỏi phòng thi?`)) return;
|
||||||
|
try {
|
||||||
|
await apiExam.removeStudent(examId, s.studentRkId);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Xóa sinh viên thất bại');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Xóa
|
Xóa khỏi phòng
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ function localInputToISO(v: string) {
|
|||||||
return new Date(v).toISOString();
|
return new Date(v).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RoomScope = 'mine' | 'all';
|
||||||
|
|
||||||
export const ExamsTab: React.FC = () => {
|
export const ExamsTab: React.FC = () => {
|
||||||
const { staff } = useAuth();
|
const { staff } = useAuth();
|
||||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
const myStaffId = staff?.id ?? 0;
|
||||||
|
|
||||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -44,10 +47,12 @@ export const ExamsTab: React.FC = () => {
|
|||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Luôn lấy đủ danh sách để toggle mine/all mượt; lọc phía client theo createdByStaffId
|
||||||
const res = await apiExam.list();
|
const res = await apiExam.list();
|
||||||
setRooms(res.data);
|
setRooms(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -61,20 +66,28 @@ export const ExamsTab: React.FC = () => {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const scopedRooms = useMemo(() => {
|
||||||
|
if (roomScope === 'all' || !myStaffId) return rooms;
|
||||||
|
return rooms.filter((r) => Number(r.createdByStaffId) === myStaffId);
|
||||||
|
}, [rooms, roomScope, myStaffId]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||||
const upcoming = rooms.filter((r) => {
|
const upcoming = scopedRooms.filter((r) => {
|
||||||
const st = r.displayStatus || r.status;
|
const st = r.displayStatus || r.status;
|
||||||
return st === 'ready' || st === 'draft';
|
return st === 'ready' || st === 'draft';
|
||||||
}).length;
|
}).length;
|
||||||
return { total: rooms.length, active, upcoming };
|
return { total: scopedRooms.length, active, upcoming };
|
||||||
}, [rooms]);
|
}, [scopedRooms]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
if (!q) return rooms;
|
if (!q) return scopedRooms;
|
||||||
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
return scopedRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
}, [rooms, search]);
|
}, [scopedRooms, search]);
|
||||||
|
|
||||||
|
const canDeleteRoom = (r: ExamRoomItem) =>
|
||||||
|
isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!name.trim() || !start || !end) return;
|
if (!name.trim() || !start || !end) return;
|
||||||
@@ -115,7 +128,11 @@ export const ExamsTab: React.FC = () => {
|
|||||||
<header className="page-header page-header--row">
|
<header className="page-header page-header--row">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Danh sách phòng thi</h1>
|
<h1 className="page-title">Danh sách phòng thi</h1>
|
||||||
<p className="page-desc">Tạo phòng, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
<p className="page-desc">
|
||||||
|
{roomScope === 'mine'
|
||||||
|
? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần'
|
||||||
|
: 'Đang xem toàn bộ phòng thi trong hệ thống'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||||
+ Tạo phòng thi
|
+ Tạo phòng thi
|
||||||
@@ -125,7 +142,7 @@ export const ExamsTab: React.FC = () => {
|
|||||||
<div className="exams-stats-row">
|
<div className="exams-stats-row">
|
||||||
<div className="learning-stat">
|
<div className="learning-stat">
|
||||||
<span className="learning-stat-value">{stats.total}</span>
|
<span className="learning-stat-value">{stats.total}</span>
|
||||||
<span className="learning-stat-label">Tổng phòng</span>
|
<span className="learning-stat-label">{roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="learning-stat learning-stat--accent">
|
<div className="learning-stat learning-stat--accent">
|
||||||
<span className="learning-stat-value">{stats.active}</span>
|
<span className="learning-stat-value">{stats.active}</span>
|
||||||
@@ -137,13 +154,30 @@ export const ExamsTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="exams-toolbar">
|
<div className="exams-toolbar" style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<div className="learning-view-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${roomScope === 'mine' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setRoomScope('mine')}
|
||||||
|
>
|
||||||
|
Phòng của tôi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${roomScope === 'all' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setRoomScope('all')}
|
||||||
|
>
|
||||||
|
Xem tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="app-pool-search exams-search"
|
className="app-pool-search exams-search"
|
||||||
placeholder="Tìm theo tên phòng thi..."
|
placeholder="Tìm theo tên phòng thi..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
style={{ flex: 1, minWidth: 200 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,19 +237,39 @@ export const ExamsTab: React.FC = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="system-empty">Đang tải...</div>
|
<div className="system-empty">Đang tải...</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="system-empty">
|
<div className="system-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
{search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
|
<p style={{ margin: 0 }}>
|
||||||
|
{search.trim()
|
||||||
|
? 'Không tìm thấy phòng thi phù hợp.'
|
||||||
|
: roomScope === 'mine'
|
||||||
|
? 'Bạn chưa tạo phòng thi nào.'
|
||||||
|
: 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
|
||||||
|
</p>
|
||||||
|
{!search.trim() && roomScope === 'mine' && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||||
|
+ Tạo phòng thi
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setRoomScope('all')}>
|
||||||
|
Xem tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="exams-card-grid">
|
<div className="exams-card-grid">
|
||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const st = statusLabel(r.displayStatus || r.status);
|
const st = statusLabel(r.displayStatus || r.status);
|
||||||
|
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
|
||||||
return (
|
return (
|
||||||
<article key={r.id} className="exam-list-card">
|
<article key={r.id} className="exam-list-card">
|
||||||
<div className="exam-list-card-head">
|
<div className="exam-list-card-head">
|
||||||
<h3>{r.name}</h3>
|
<h3>{r.name}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{mine && <span className="badge badge-warning" style={{ fontSize: '0.7rem' }}>Của tôi</span>}
|
||||||
<span className={`badge ${st.cls}`}>{st.text}</span>
|
<span className={`badge ${st.cls}`}>{st.text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="exam-list-card-meta">
|
<div className="exam-list-card-meta">
|
||||||
<div>
|
<div>
|
||||||
<span className="exam-list-card-label">Thời gian</span>
|
<span className="exam-list-card-label">Thời gian</span>
|
||||||
@@ -232,7 +286,7 @@ export const ExamsTab: React.FC = () => {
|
|||||||
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}>
|
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}>
|
||||||
Mở phòng thi
|
Mở phòng thi
|
||||||
</button>
|
</button>
|
||||||
{isSuperAdmin && (
|
{canDeleteRoom(r) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
@@ -249,14 +303,6 @@ export const ExamsTab: React.FC = () => {
|
|||||||
transition: 'var(--transition)',
|
transition: 'var(--transition)',
|
||||||
margin: 0
|
margin: 0
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'var(--danger)';
|
|
||||||
e.currentTarget.style.color = '#fff';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
|
||||||
e.currentTarget.style.color = 'var(--danger)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Xóa
|
Xóa
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from './learningUtils';
|
} from './learningUtils';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'timetable';
|
type ViewMode = 'list' | 'timetable';
|
||||||
|
type RoomScope = 'mine' | 'all';
|
||||||
|
|
||||||
export const LearningTab: React.FC = () => {
|
export const LearningTab: React.FC = () => {
|
||||||
const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
|
const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
|
||||||
@@ -40,7 +41,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||||
const [myClasses, setMyClasses] = useState<number[]>([]);
|
const [myClasses, setMyClasses] = useState<number[]>([]);
|
||||||
|
|
||||||
const refreshMyClasses = () => {
|
const refreshMyClasses = () => {
|
||||||
@@ -75,8 +76,10 @@ export const LearningTab: React.FC = () => {
|
|||||||
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
||||||
|
|
||||||
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
||||||
|
const [showAddMyClassModal, setShowAddMyClassModal] = useState<boolean>(false);
|
||||||
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
||||||
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
||||||
|
const [loadingClassList, setLoadingClassList] = useState(false);
|
||||||
|
|
||||||
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
|
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | null>(null);
|
||||||
const [conflictWarning, setConflictWarning] = useState<{ cls: ClassItem; conflicts: ScheduleConflictItem[] } | null>(null);
|
const [conflictWarning, setConflictWarning] = useState<{ cls: ClassItem; conflicts: ScheduleConflictItem[] } | null>(null);
|
||||||
@@ -116,32 +119,53 @@ export const LearningTab: React.FC = () => {
|
|||||||
|
|
||||||
const filteredClasses = useMemo(() => {
|
const filteredClasses = useMemo(() => {
|
||||||
const list = filterActiveClasses(activeClasses, searchQuery, dayFilter);
|
const list = filterActiveClasses(activeClasses, searchQuery, dayFilter);
|
||||||
if (onlyMyClasses) {
|
if (roomScope === 'mine') {
|
||||||
return list.filter(c => myClasses.includes(c.rkId));
|
return list.filter(c => myClasses.includes(c.rkId));
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [activeClasses, searchQuery, dayFilter, onlyMyClasses, myClasses]);
|
}, [activeClasses, searchQuery, dayFilter, roomScope, myClasses]);
|
||||||
|
|
||||||
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
const myActiveClasses = useMemo(
|
||||||
const todayClassCount = useMemo(
|
() => activeClasses.filter(c => myClasses.includes(c.rkId)),
|
||||||
() => activeClasses.filter(classHasToday).length,
|
[activeClasses, myClasses]
|
||||||
[activeClasses]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAddClass = async () => {
|
const weeklyShifts = useMemo(
|
||||||
setShowAddClassModal(true);
|
() => countWeeklyShifts(roomScope === 'mine' ? myActiveClasses : activeClasses),
|
||||||
|
[roomScope, myActiveClasses, activeClasses]
|
||||||
|
);
|
||||||
|
const todayClassCount = useMemo(
|
||||||
|
() => (roomScope === 'mine' ? myActiveClasses : activeClasses).filter(classHasToday).length,
|
||||||
|
[roomScope, myActiveClasses, activeClasses]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadAllClassesForModal = async () => {
|
||||||
|
setLoadingClassList(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
||||||
setAllClasses(res.data || []);
|
setAllClasses(res.data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading classes:', err);
|
console.error('Error loading classes:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingClassList(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenAddClass = async () => {
|
||||||
|
setSearchClassQuery('');
|
||||||
|
setShowAddClassModal(true);
|
||||||
|
await loadAllClassesForModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddMyClass = async () => {
|
||||||
|
setSearchClassQuery('');
|
||||||
|
setShowAddMyClassModal(true);
|
||||||
|
await loadAllClassesForModal();
|
||||||
|
};
|
||||||
|
|
||||||
const handleActivateClass = async (cls: ClassItem) => {
|
const handleActivateClass = async (cls: ClassItem) => {
|
||||||
setShowAddClassModal(false);
|
setShowAddClassModal(false);
|
||||||
try {
|
try {
|
||||||
// Kiểm tra trùng sinh viên với lớp đang giám sát có lịch học
|
|
||||||
const conflictRes = await apiFetchScheduleConflicts(cls.rkId);
|
const conflictRes = await apiFetchScheduleConflicts(cls.rkId);
|
||||||
if (conflictRes.conflicts && conflictRes.conflicts.length > 0) {
|
if (conflictRes.conflicts && conflictRes.conflicts.length > 0) {
|
||||||
setConflictWarning({ cls, conflicts: conflictRes.conflicts });
|
setConflictWarning({ cls, conflicts: conflictRes.conflicts });
|
||||||
@@ -156,6 +180,12 @@ export const LearningTab: React.FC = () => {
|
|||||||
const doActivateClass = async (cls: ClassItem) => {
|
const doActivateClass = async (cls: ClassItem) => {
|
||||||
try {
|
try {
|
||||||
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
await apiSaveAllowedApps(cls.rkId, DEFAULT_ALLOWED_APPS);
|
||||||
|
// Tự thêm vào "lớp của tôi" khi kích hoạt lịch
|
||||||
|
if (!myClasses.includes(cls.rkId)) {
|
||||||
|
setMyClasses((prev) => [...prev, cls.rkId]);
|
||||||
|
apiMyClasses.add(cls.rkId).catch(() => {});
|
||||||
|
window.dispatchEvent(new Event('my-classes-updated'));
|
||||||
|
}
|
||||||
await loadActiveClasses();
|
await loadActiveClasses();
|
||||||
handleOpenConfig(cls as ActiveClassItem);
|
handleOpenConfig(cls as ActiveClassItem);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -245,25 +275,45 @@ export const LearningTab: React.FC = () => {
|
|||||||
!activeClasses.some(ac => ac.rkId === c.rkId)
|
!activeClasses.some(ac => ac.rkId === c.rkId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredMyClassCandidates = allClasses.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(searchClassQuery.toLowerCase()) ||
|
||||||
|
c.classCode.toLowerCase().includes(searchClassQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<div className="tab-page-toolbar">
|
<div className="tab-page-toolbar">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<h1>Quản lý lịch học & Giám sát</h1>
|
<h1>Quản lý lịch học & Giám sát</h1>
|
||||||
<p>Chọn lớp, lập lịch theo tuần và giám sát sinh viên — xem dạng danh sách hoặc thời khóa biểu</p>
|
<p>
|
||||||
|
{roomScope === 'mine'
|
||||||
|
? 'Mặc định chỉ hiện lớp của bạn — thêm lớp hoặc chuyển sang xem tất cả khi cần'
|
||||||
|
: 'Đang xem toàn bộ lớp đang giám sát trong hệ thống'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={handleOpenAddClass}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddMyClass}>
|
||||||
|
+ Thêm lớp của tôi
|
||||||
|
</button>
|
||||||
|
{roomScope === 'all' && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddClass}>
|
||||||
+ Chọn lớp & Tạo lịch
|
+ Chọn lớp & Tạo lịch
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-page-body" style={{ gap: '1rem' }}>
|
<div className="tab-page-body" style={{ gap: '1rem' }}>
|
||||||
<div className="learning-stats-row">
|
<div className="learning-stats-row">
|
||||||
<div className="learning-stat">
|
<div className="learning-stat">
|
||||||
<span className="learning-stat-value">{activeClasses.length}</span>
|
<span className="learning-stat-value">
|
||||||
<span className="learning-stat-label">Lớp đang giám sát</span>
|
{roomScope === 'mine' ? myActiveClasses.length : activeClasses.length}
|
||||||
|
</span>
|
||||||
|
<span className="learning-stat-label">
|
||||||
|
{roomScope === 'mine' ? 'Lớp của tôi' : 'Lớp đang giám sát'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="learning-stat">
|
<div className="learning-stat">
|
||||||
<span className="learning-stat-value">{weeklyShifts}</span>
|
<span className="learning-stat-value">{weeklyShifts}</span>
|
||||||
@@ -281,6 +331,22 @@ export const LearningTab: React.FC = () => {
|
|||||||
|
|
||||||
<div className="content-card learning-toolbar-card">
|
<div className="content-card learning-toolbar-card">
|
||||||
<div className="learning-toolbar">
|
<div className="learning-toolbar">
|
||||||
|
<div className="learning-view-toggle" style={{ marginRight: '0.25rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${roomScope === 'mine' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setRoomScope('mine')}
|
||||||
|
>
|
||||||
|
⭐ Lớp của tôi
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`learning-view-btn${roomScope === 'all' ? ' learning-view-btn--active' : ''}`}
|
||||||
|
onClick={() => setRoomScope('all')}
|
||||||
|
>
|
||||||
|
Xem tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="app-pool-search learning-search"
|
className="app-pool-search learning-search"
|
||||||
@@ -288,14 +354,6 @@ export const LearningTab: React.FC = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.88rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', userSelect: 'none', marginLeft: '0.5rem' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={onlyMyClasses}
|
|
||||||
onChange={e => setOnlyMyClasses(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>⭐ Lớp của tôi</span>
|
|
||||||
</label>
|
|
||||||
<div className="learning-view-toggle">
|
<div className="learning-view-toggle">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -320,7 +378,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
className={`learning-day-chip${dayFilter === null ? ' learning-day-chip--active' : ''}`}
|
className={`learning-day-chip${dayFilter === null ? ' learning-day-chip--active' : ''}`}
|
||||||
onClick={() => setDayFilter(null)}
|
onClick={() => setDayFilter(null)}
|
||||||
>
|
>
|
||||||
Tất cả
|
Tất cả ngày
|
||||||
</button>
|
</button>
|
||||||
{WEEK_DAYS.map((label, day) => (
|
{WEEK_DAYS.map((label, day) => (
|
||||||
<button
|
<button
|
||||||
@@ -344,7 +402,26 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && activeClasses.length === 0 && (
|
{!loading && roomScope === 'mine' && myClasses.length === 0 && (
|
||||||
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
|
<div style={{ fontSize: '2.5rem', lineHeight: 1 }}>⭐</div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500, textAlign: 'center', maxWidth: 420 }}>
|
||||||
|
Chưa có lớp nào trong danh sách của bạn.
|
||||||
|
<br />
|
||||||
|
Tìm và thêm lớp để theo dõi nhanh hơn.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', justifyContent: 'center', marginTop: '0.5rem' }}>
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddMyClass}>
|
||||||
|
+ Thêm lớp của tôi
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setRoomScope('all')}>
|
||||||
|
Xem tất cả lớp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && roomScope === 'all' && activeClasses.length === 0 && (
|
||||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
@@ -359,24 +436,33 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
{!loading && !(roomScope === 'mine' && myClasses.length === 0) && !(roomScope === 'all' && activeClasses.length === 0) && filteredClasses.length === 0 && (
|
||||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500, textAlign: 'center', maxWidth: 440 }}>
|
||||||
|
{roomScope === 'mine'
|
||||||
|
? 'Lớp của bạn chưa có lịch giám sát khớp bộ lọc, hoặc chưa được kích hoạt lịch.'
|
||||||
|
: 'Không tìm thấy lớp phù hợp với bộ lọc hiện tại.'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', justifyContent: 'center', marginTop: '0.5rem' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setDayFilter(null);
|
setDayFilter(null);
|
||||||
setOnlyMyClasses(false);
|
|
||||||
}}
|
}}
|
||||||
style={{ marginTop: '0.5rem' }}
|
|
||||||
>
|
>
|
||||||
Xóa bộ lọc
|
Xóa bộ lọc ngày/tìm kiếm
|
||||||
</button>
|
</button>
|
||||||
|
{roomScope === 'mine' && (
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddMyClass}>
|
||||||
|
+ Thêm lớp của tôi
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -634,7 +720,9 @@ export const LearningTab: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
{filteredAddClasses.length === 0 ? (
|
{loadingClassList ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Đang tải danh sách lớp...</p>
|
||||||
|
) : filteredAddClasses.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Không tìm thấy lớp phù hợp.</p>
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Không tìm thấy lớp phù hợp.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
@@ -648,27 +736,13 @@ export const LearningTab: React.FC = () => {
|
|||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
transition: 'var(--transition)'
|
transition: 'var(--transition)'
|
||||||
}}
|
}}>
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
|
|
||||||
e.currentTarget.style.backgroundColor = '#fff';
|
|
||||||
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
|
||||||
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.9rem' }}>{c.classCode}</div>
|
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.9rem' }}>{c.classCode}</div>
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c.name}</div>
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
Kích hoạt
|
Kích hoạt
|
||||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -683,6 +757,103 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAddMyClassModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAddMyClassModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '560px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title">Thêm lớp của tôi</h2>
|
||||||
|
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||||
|
Tìm lớp và thêm vào danh sách cá nhân. Có thể kích hoạt lịch nếu lớp chưa giám sát.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close-btn" onClick={() => setShowAddMyClassModal(false)} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
|
||||||
|
<div className="search-input-wrapper" style={{ width: '100%', marginBottom: '1rem' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Tìm tên lớp hoặc mã lớp..."
|
||||||
|
value={searchClassQuery}
|
||||||
|
onChange={e => setSearchClassQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="search-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '360px', overflowY: 'auto' }}>
|
||||||
|
{loadingClassList ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0' }}>Đang tải danh sách lớp...</p>
|
||||||
|
) : filteredMyClassCandidates.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0' }}>Không tìm thấy lớp phù hợp.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{filteredMyClassCandidates.map(c => {
|
||||||
|
const isMine = myClasses.includes(c.rkId);
|
||||||
|
const isActive = activeClasses.some(ac => ac.rkId === c.rkId);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={c.rkId}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.85rem 1rem',
|
||||||
|
backgroundColor: isMine ? 'rgba(234, 179, 8, 0.08)' : 'var(--bg-app)',
|
||||||
|
border: `1px solid ${isMine ? 'rgba(234, 179, 8, 0.35)' : 'var(--border-color)'}`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.9rem' }}>{c.classCode}</span>
|
||||||
|
{isMine && <span className="badge badge-warning" style={{ fontSize: '0.7rem' }}>Đã thêm</span>}
|
||||||
|
{isActive && <span className="badge badge-info" style={{ fontSize: '0.7rem' }}>Đang giám sát</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c.name}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.35rem', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${isMine ? 'btn-secondary' : 'btn-primary'}`}
|
||||||
|
onClick={() => toggleMyClass(c.rkId, c.name, c.classCode)}
|
||||||
|
>
|
||||||
|
{isMine ? 'Bỏ khỏi danh sách' : '★ Thêm'}
|
||||||
|
</button>
|
||||||
|
{!isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
title="Kích hoạt lịch giám sát"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddMyClassModal(false);
|
||||||
|
void handleActivateClass(c);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tạo lịch
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowAddMyClassModal(false)}>Xong</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{proctorStudent && (
|
{proctorStudent && (
|
||||||
<LiveProctorModal
|
<LiveProctorModal
|
||||||
studentId={proctorStudent.id}
|
studentId={proctorStudent.id}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
|
|||||||
return ExamRoomIsLive(room, now)
|
return ExamRoomIsLive(room, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExamRoomCanExtend — gia hạn giờ kết thúc khi phòng đang thi.
|
||||||
|
func ExamRoomCanExtend(room models.ExamRoom, now time.Time) bool {
|
||||||
|
return ExamRoomIsLive(room, now)
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
|
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
|
||||||
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
internalDb "server/internal/db"
|
internalDb "server/internal/db"
|
||||||
|
"server/internal/middleware"
|
||||||
"server/internal/models"
|
"server/internal/models"
|
||||||
internalWs "server/internal/websocket"
|
internalWs "server/internal/websocket"
|
||||||
|
|
||||||
@@ -120,8 +121,15 @@ func deliverExamPaper(db *gorm.DB, enrollmentID uint) {
|
|||||||
// GET /api/exam-rooms
|
// GET /api/exam-rooms
|
||||||
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
|
mineOnly := c.Query("mine") == "1" || c.Query("mine") == "true"
|
||||||
|
|
||||||
|
q := db.Order("start_time desc")
|
||||||
|
if mineOnly && staffID > 0 {
|
||||||
|
q = q.Where("created_by_staff_id = ?", staffID)
|
||||||
|
}
|
||||||
var rooms []models.ExamRoom
|
var rooms []models.ExamRoom
|
||||||
if err := db.Order("start_time desc").Find(&rooms).Error; err != nil {
|
if err := q.Find(&rooms).Error; err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
type row struct {
|
type row struct {
|
||||||
@@ -129,6 +137,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
|||||||
StudentCount int `json:"studentCount"`
|
StudentCount int `json:"studentCount"`
|
||||||
PaperCount int `json:"paperCount"`
|
PaperCount int `json:"paperCount"`
|
||||||
DisplayStatus string `json:"displayStatus"`
|
DisplayStatus string `json:"displayStatus"`
|
||||||
|
CreatedByStaffID uint `json:"createdByStaffId"`
|
||||||
}
|
}
|
||||||
out := make([]row, 0, len(rooms))
|
out := make([]row, 0, len(rooms))
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -144,6 +153,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
|||||||
StudentCount: int(sc),
|
StudentCount: int(sc),
|
||||||
PaperCount: int(pc),
|
PaperCount: int(pc),
|
||||||
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
||||||
|
CreatedByStaffID: r.CreatedByStaffID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"data": out})
|
return c.JSON(fiber.Map{"data": out})
|
||||||
@@ -183,6 +193,7 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
AllowedApps: apps,
|
AllowedApps: apps,
|
||||||
QuizURL: strings.TrimSpace(req.QuizURL),
|
QuizURL: strings.TrimSpace(req.QuizURL),
|
||||||
Status: models.ExamStatusDraft,
|
Status: models.ExamStatusDraft,
|
||||||
|
CreatedByStaffID: middleware.StaffIDFromCtx(c),
|
||||||
}
|
}
|
||||||
if err := db.Create(&room).Error; err != nil {
|
if err := db.Create(&room).Error; err != nil {
|
||||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -259,8 +270,9 @@ func GetExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
"editable": internalDb.ExamRoomEditable(room),
|
"editable": internalDb.ExamRoomEditable(room),
|
||||||
"prepEditable": internalDb.ExamRoomPrepEditable(room, now),
|
"prepEditable": internalDb.ExamRoomPrepEditable(room, now),
|
||||||
"canPublish": room.Status == models.ExamStatusDraft,
|
"canPublish": room.Status == models.ExamStatusDraft,
|
||||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()),
|
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, now),
|
||||||
"canCancel": internalDb.ExamRoomCanCancel(room, time.Now()),
|
"canCancel": internalDb.ExamRoomCanCancel(room, now),
|
||||||
|
"canExtend": internalDb.ExamRoomCanExtend(room, now),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,8 +319,15 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ds := internalDb.ExamDisplayStatus(room, now)
|
||||||
if !internalDb.ExamRoomEditable(room) {
|
if !internalDb.ExamRoomEditable(room) {
|
||||||
if room.Status == models.ExamStatusReady {
|
if ds == "active" {
|
||||||
|
// Đang thi: chỉ cho sửa app được phép + giờ kết thúc (gia hạn)
|
||||||
|
if req.Name != nil || req.StartTime != nil || req.QuizURL != nil {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Khi đang thi chỉ được sửa ứng dụng được phép hoặc gia hạn giờ kết thúc"})
|
||||||
|
}
|
||||||
|
} else if room.Status == models.ExamStatusReady {
|
||||||
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
|
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
|
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
|
||||||
}
|
}
|
||||||
@@ -331,6 +350,14 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"})
|
return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"})
|
||||||
}
|
}
|
||||||
|
if ds == "active" {
|
||||||
|
if !t.After(now) {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc mới phải sau thời điểm hiện tại"})
|
||||||
|
}
|
||||||
|
if !t.After(room.EndTime) {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Gia hạn phải chọn giờ kết thúc muộn hơn giờ hiện tại của phòng"})
|
||||||
|
}
|
||||||
|
}
|
||||||
room.EndTime = t
|
room.EndTime = t
|
||||||
}
|
}
|
||||||
if !room.EndTime.After(room.StartTime) {
|
if !room.EndTime.After(room.StartTime) {
|
||||||
@@ -420,13 +447,54 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/exam-rooms/:id/extend — gia hạn thêm phút cho phòng đang thi
|
||||||
|
func ExtendExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
id, err := parseUintParam(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
if req.Minutes < 1 || req.Minutes > 480 {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "Số phút gia hạn phải từ 1 đến 480"})
|
||||||
|
}
|
||||||
|
var room models.ExamRoom
|
||||||
|
if err := db.First(&room, id).Error; err != nil {
|
||||||
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if !internalDb.ExamRoomCanExtend(room, now) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Chỉ gia hạn được phòng thi đang diễn ra"})
|
||||||
|
}
|
||||||
|
base := room.EndTime
|
||||||
|
if !base.After(now) {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
room.EndTime = base.Add(time.Duration(req.Minutes) * time.Minute)
|
||||||
|
if err := db.Save(&room).Error; err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"ok": true,
|
||||||
|
"room": room,
|
||||||
|
"displayStatus": internalDb.ExamDisplayStatus(room, time.Now()),
|
||||||
|
"addedMinutes": req.Minutes,
|
||||||
|
"endTime": room.EndTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/exam-rooms/:id
|
// DELETE /api/exam-rooms/:id
|
||||||
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
return func(c *fiber.Ctx) error {
|
||||||
|
staffID := middleware.StaffIDFromCtx(c)
|
||||||
email, _ := c.Locals("staffEmail").(string)
|
email, _ := c.Locals("staffEmail").(string)
|
||||||
if email != "phuocntb@rikkeiacademy.com" {
|
isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := parseUintParam(c, "id")
|
id, err := parseUintParam(c, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,6 +504,9 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err := db.First(&room, id).Error; err != nil {
|
if err := db.First(&room, id).Error; err != nil {
|
||||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||||
}
|
}
|
||||||
|
if !isSuperAdmin && (room.CreatedByStaffID == 0 || room.CreatedByStaffID != staffID) {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Chỉ người tạo phòng hoặc admin mới được xóa"})
|
||||||
|
}
|
||||||
if internalDb.ExamRoomIsLive(room, time.Now()) {
|
if internalDb.ExamRoomIsLive(room, time.Now()) {
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"})
|
return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"})
|
||||||
}
|
}
|
||||||
@@ -451,6 +522,7 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
_ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error
|
_ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error
|
||||||
}
|
}
|
||||||
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error
|
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error
|
||||||
|
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error
|
||||||
_ = db.Delete(&room).Error
|
_ = db.Delete(&room).Error
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -593,8 +665,9 @@ func RemoveExamRoomStudentHandler(db *gorm.DB) fiber.Handler {
|
|||||||
if err := db.First(&room, roomID).Error; err != nil {
|
if err := db.First(&room, roomID).Error; err != nil {
|
||||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||||
}
|
}
|
||||||
if !internalDb.ExamRoomPrepEditable(room, time.Now()) {
|
ds := internalDb.ExamDisplayStatus(room, time.Now())
|
||||||
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa sinh viên"})
|
if ds == models.ExamStatusEnded {
|
||||||
|
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã kết thúc — không thể xóa sinh viên"})
|
||||||
}
|
}
|
||||||
_ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error
|
_ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type ExamRoom struct {
|
|||||||
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
|
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
|
||||||
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
|
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
|
||||||
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
|
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
|
||||||
|
CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ExamRoom) TableName() string { return "exam_rooms" }
|
func (ExamRoom) TableName() string { return "exam_rooms" }
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ func main() {
|
|||||||
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
|
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
|
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
|
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
|
||||||
|
staff.Post("/exam-rooms/:id/extend", handlers.ExtendExamRoomHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
|
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
|
||||||
staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
|
staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
|
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
|
||||||
|
|||||||
Reference in New Issue
Block a user