up
Some checks failed
Deploy on Master Change / deploy (push) Failing after 1m23s

This commit is contained in:
2026-07-20 12:38:02 +07:00
parent 6ee9773f7f
commit 232f9873a0
8 changed files with 516 additions and 121 deletions

View File

@@ -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');

View File

@@ -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>

View File

@@ -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 đ 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,18 +237,38 @@ 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>
<span className={`badge ${st.cls}`}>{st.text}</span> <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>
</div>
</div> </div>
<div className="exam-list-card-meta"> <div className="exam-list-card-meta">
<div> <div>
@@ -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>

View File

@@ -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ịch học & Giám sát</h1> <h1>Quản lịch học & Giám sát</h1>
<p>Chọn lớp, lập lịch theo tuần 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 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
</button>
)}
</div> </div>
<button className="btn btn-primary" onClick={handleOpenAddClass}>
+ Chọn lớp & Tạo lịch
</button>
</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 lớp nào trong danh sách của bạn.
<br />
Tìm 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 }}>
<button {roomScope === 'mine'
className="btn btn-secondary" ? '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.'
onClick={() => { : 'Không tìm thấy lớp phù hợp với bộ lọc hiện tại.'}
setSearchQuery(''); </p>
setDayFilter(null); <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', justifyContent: 'center', marginTop: '0.5rem' }}>
setOnlyMyClasses(false); <button
}} className="btn btn-secondary"
style={{ marginTop: '0.5rem' }} onClick={() => {
> setSearchQuery('');
Xóa bộ lọc setDayFilter(null);
</button> }}
>
Xóa bộ lọc ngày/tìm kiếm
</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 thêm vào danh sách nhân. 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">&times;</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}

View File

@@ -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()

View File

@@ -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,15 +121,23 @@ 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 {
models.ExamRoom models.ExamRoom
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()
@@ -140,10 +149,11 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error
_ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error _ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error
out = append(out, row{ out = append(out, row{
ExamRoom: r, ExamRoom: r,
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})
@@ -177,12 +187,13 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
} }
apps := strings.TrimSpace(req.AllowedApps) apps := strings.TrimSpace(req.AllowedApps)
room := models.ExamRoom{ room := models.ExamRoom{
Name: req.Name, Name: req.Name,
StartTime: start, StartTime: start,
EndTime: end, EndTime: end,
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})

View File

@@ -11,18 +11,19 @@ const (
// ExamRoom — phòng thi độc lập với lớp học // ExamRoom — phòng thi độc lập với lớp học
type ExamRoom struct { type ExamRoom struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:255;not null" json:"name"` Name string `gorm:"column:name;size:255;not null" json:"name"`
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"` StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"` EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"` AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"` QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"` GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl"`
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" }

View File

@@ -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))