This commit is contained in:
@@ -933,6 +933,7 @@ export interface ExamRoomItem {
|
||||
studentCount: number;
|
||||
paperCount: number;
|
||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||
createdByStaffId?: number;
|
||||
}
|
||||
|
||||
export interface ExamPaperResource {
|
||||
@@ -985,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const res = await staffFetch('/exam-rooms');
|
||||
list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const q = opts?.mine ? '?mine=1' : '';
|
||||
const res = await staffFetch(`/exam-rooms${q}`);
|
||||
if (!res.ok) await parseError(res, 'Failed');
|
||||
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');
|
||||
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 }>) => {
|
||||
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');
|
||||
|
||||
@@ -70,6 +70,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [canPublish, setCanPublish] = useState(false);
|
||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||
const [canCancel, setCanCancel] = useState(false);
|
||||
const [canExtend, setCanExtend] = useState(false);
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
||||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||||
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 anyPaperSent = students.some((s) => s.paperSentAt);
|
||||
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
|
||||
const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -118,6 +121,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
setCanPublish(data.canPublish);
|
||||
setCanUnpublish(data.canUnpublish);
|
||||
setCanCancel(data.canCancel);
|
||||
setCanExtend(!!data.canExtend || data.displayStatus === 'active');
|
||||
setPapers(data.papers);
|
||||
setStudents(data.students);
|
||||
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) => {
|
||||
await apiExam.addStudents(examId, [studentRkId]);
|
||||
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 term = searchQuery.toLowerCase().trim();
|
||||
if (!term) return true;
|
||||
@@ -667,6 +700,23 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{canCancel && (
|
||||
<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 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề
|
||||
@@ -713,7 +763,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Thông tin phòng thi</div>
|
||||
<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>
|
||||
<label className="login-field">
|
||||
<span>Tên phòng thi</span>
|
||||
@@ -754,13 +808,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<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>
|
||||
{editable && (
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
||||
{(editable || canExtend) && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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)}>
|
||||
Nhắn tin
|
||||
</button>
|
||||
{prepEditable && (
|
||||
{canRemoveStudents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
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>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -32,9 +32,12 @@ function localInputToISO(v: string) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
type RoomScope = 'mine' | 'all';
|
||||
|
||||
export const ExamsTab: React.FC = () => {
|
||||
const { staff } = useAuth();
|
||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||
const myStaffId = staff?.id ?? 0;
|
||||
|
||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -44,10 +47,12 @@ export const ExamsTab: React.FC = () => {
|
||||
const [end, setEnd] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
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();
|
||||
setRooms(res.data);
|
||||
} catch (e) {
|
||||
@@ -61,20 +66,28 @@ export const ExamsTab: React.FC = () => {
|
||||
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 active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = rooms.filter((r) => {
|
||||
const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = scopedRooms.filter((r) => {
|
||||
const st = r.displayStatus || r.status;
|
||||
return st === 'ready' || st === 'draft';
|
||||
}).length;
|
||||
return { total: rooms.length, active, upcoming };
|
||||
}, [rooms]);
|
||||
return { total: scopedRooms.length, active, upcoming };
|
||||
}, [scopedRooms]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rooms;
|
||||
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [rooms, search]);
|
||||
if (!q) return scopedRooms;
|
||||
return scopedRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [scopedRooms, search]);
|
||||
|
||||
const canDeleteRoom = (r: ExamRoomItem) =>
|
||||
isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !start || !end) return;
|
||||
@@ -115,7 +128,11 @@ export const ExamsTab: React.FC = () => {
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<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>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
+ Tạo phòng thi
|
||||
@@ -125,7 +142,7 @@ export const ExamsTab: React.FC = () => {
|
||||
<div className="exams-stats-row">
|
||||
<div className="learning-stat">
|
||||
<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 className="learning-stat learning-stat--accent">
|
||||
<span className="learning-stat-value">{stats.active}</span>
|
||||
@@ -137,13 +154,30 @@ export const ExamsTab: React.FC = () => {
|
||||
</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
|
||||
type="search"
|
||||
className="app-pool-search exams-search"
|
||||
placeholder="Tìm theo tên phòng thi..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -203,19 +237,39 @@ export const ExamsTab: React.FC = () => {
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="system-empty">
|
||||
{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.'}
|
||||
<div className="system-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<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 className="exams-card-grid">
|
||||
{filtered.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
|
||||
return (
|
||||
<article key={r.id} className="exam-list-card">
|
||||
<div className="exam-list-card-head">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exam-list-card-meta">
|
||||
<div>
|
||||
<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 }}>
|
||||
Mở phòng thi
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
{canDeleteRoom(r) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
@@ -249,14 +303,6 @@ export const ExamsTab: React.FC = () => {
|
||||
transition: 'var(--transition)',
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from './learningUtils';
|
||||
|
||||
type ViewMode = 'list' | 'timetable';
|
||||
type RoomScope = 'mine' | 'all';
|
||||
|
||||
export const LearningTab: React.FC = () => {
|
||||
const [activeClasses, setActiveClasses] = useState<ActiveClassItem[]>([]);
|
||||
@@ -40,7 +41,7 @@ export const LearningTab: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
||||
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||
const [myClasses, setMyClasses] = useState<number[]>([]);
|
||||
|
||||
const refreshMyClasses = () => {
|
||||
@@ -75,8 +76,10 @@ export const LearningTab: React.FC = () => {
|
||||
const [studentsLogs, setStudentsLogs] = useState<StudentSessionLogItem[]>([]);
|
||||
|
||||
const [showAddClassModal, setShowAddClassModal] = useState<boolean>(false);
|
||||
const [showAddMyClassModal, setShowAddMyClassModal] = useState<boolean>(false);
|
||||
const [allClasses, setAllClasses] = useState<ClassItem[]>([]);
|
||||
const [searchClassQuery, setSearchClassQuery] = useState<string>('');
|
||||
const [loadingClassList, setLoadingClassList] = useState(false);
|
||||
|
||||
const [proctorStudent, setProctorStudent] = useState<{ id: number; name: string; code: string } | 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 list = filterActiveClasses(activeClasses, searchQuery, dayFilter);
|
||||
if (onlyMyClasses) {
|
||||
if (roomScope === 'mine') {
|
||||
return list.filter(c => myClasses.includes(c.rkId));
|
||||
}
|
||||
return list;
|
||||
}, [activeClasses, searchQuery, dayFilter, onlyMyClasses, myClasses]);
|
||||
}, [activeClasses, searchQuery, dayFilter, roomScope, myClasses]);
|
||||
|
||||
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
||||
const todayClassCount = useMemo(
|
||||
() => activeClasses.filter(classHasToday).length,
|
||||
[activeClasses]
|
||||
const myActiveClasses = useMemo(
|
||||
() => activeClasses.filter(c => myClasses.includes(c.rkId)),
|
||||
[activeClasses, myClasses]
|
||||
);
|
||||
|
||||
const handleOpenAddClass = async () => {
|
||||
setShowAddClassModal(true);
|
||||
const weeklyShifts = useMemo(
|
||||
() => 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 {
|
||||
const res = await apiFetchClasses({ page: 1, pageSize: 1000 });
|
||||
setAllClasses(res.data || []);
|
||||
} catch (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) => {
|
||||
setShowAddClassModal(false);
|
||||
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);
|
||||
if (conflictRes.conflicts && conflictRes.conflicts.length > 0) {
|
||||
setConflictWarning({ cls, conflicts: conflictRes.conflicts });
|
||||
@@ -156,6 +180,12 @@ export const LearningTab: React.FC = () => {
|
||||
const doActivateClass = async (cls: ClassItem) => {
|
||||
try {
|
||||
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();
|
||||
handleOpenConfig(cls as ActiveClassItem);
|
||||
} catch (err) {
|
||||
@@ -245,25 +275,45 @@ export const LearningTab: React.FC = () => {
|
||||
!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 (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<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>
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body" style={{ gap: '1rem' }}>
|
||||
<div className="learning-stats-row">
|
||||
<div className="learning-stat">
|
||||
<span className="learning-stat-value">{activeClasses.length}</span>
|
||||
<span className="learning-stat-label">Lớp đang giám sát</span>
|
||||
<span className="learning-stat-value">
|
||||
{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 className="learning-stat">
|
||||
<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="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
|
||||
type="search"
|
||||
className="app-pool-search learning-search"
|
||||
@@ -288,14 +354,6 @@ export const LearningTab: React.FC = () => {
|
||||
value={searchQuery}
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -320,7 +378,7 @@ export const LearningTab: React.FC = () => {
|
||||
className={`learning-day-chip${dayFilter === null ? ' learning-day-chip--active' : ''}`}
|
||||
onClick={() => setDayFilter(null)}
|
||||
>
|
||||
Tất cả
|
||||
Tất cả ngày
|
||||
</button>
|
||||
{WEEK_DAYS.map((label, day) => (
|
||||
<button
|
||||
@@ -344,7 +402,26 @@ export const LearningTab: React.FC = () => {
|
||||
</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' }}>
|
||||
<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" />
|
||||
@@ -359,24 +436,33 @@ export const LearningTab: React.FC = () => {
|
||||
</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' }}>
|
||||
<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" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</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
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
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>
|
||||
{roomScope === 'mine' && (
|
||||
<button className="btn btn-primary" onClick={handleOpenAddMyClass}>
|
||||
+ Thêm lớp của tôi
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -634,7 +720,9 @@ export const LearningTab: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
) : (
|
||||
<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)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
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 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>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
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>
|
||||
</li>
|
||||
))}
|
||||
@@ -683,6 +757,103 @@ export const LearningTab: React.FC = () => {
|
||||
</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 && (
|
||||
<LiveProctorModal
|
||||
studentId={proctorStudent.id}
|
||||
|
||||
@@ -54,6 +54,11 @@ func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
|
||||
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.
|
||||
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/middleware"
|
||||
"server/internal/models"
|
||||
internalWs "server/internal/websocket"
|
||||
|
||||
@@ -120,8 +121,15 @@ func deliverExamPaper(db *gorm.DB, enrollmentID uint) {
|
||||
// GET /api/exam-rooms
|
||||
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
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
|
||||
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()})
|
||||
}
|
||||
type row struct {
|
||||
@@ -129,6 +137,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCount int `json:"studentCount"`
|
||||
PaperCount int `json:"paperCount"`
|
||||
DisplayStatus string `json:"displayStatus"`
|
||||
CreatedByStaffID uint `json:"createdByStaffId"`
|
||||
}
|
||||
out := make([]row, 0, len(rooms))
|
||||
now := time.Now()
|
||||
@@ -144,6 +153,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCount: int(sc),
|
||||
PaperCount: int(pc),
|
||||
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
||||
CreatedByStaffID: r.CreatedByStaffID,
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": out})
|
||||
@@ -183,6 +193,7 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
AllowedApps: apps,
|
||||
QuizURL: strings.TrimSpace(req.QuizURL),
|
||||
Status: models.ExamStatusDraft,
|
||||
CreatedByStaffID: middleware.StaffIDFromCtx(c),
|
||||
}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
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),
|
||||
"prepEditable": internalDb.ExamRoomPrepEditable(room, now),
|
||||
"canPublish": room.Status == models.ExamStatusDraft,
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()),
|
||||
"canCancel": internalDb.ExamRoomCanCancel(room, time.Now()),
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, 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 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
now := time.Now()
|
||||
ds := internalDb.ExamDisplayStatus(room, now)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
email, _ := c.Locals("staffEmail").(string)
|
||||
if 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"})
|
||||
}
|
||||
isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
|
||||
|
||||
id, err := parseUintParam(c, "id")
|
||||
if err != nil {
|
||||
@@ -436,6 +504,9 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := db.First(&room, id).Error; err != nil {
|
||||
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()) {
|
||||
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_room_id = ?", id).Delete(&models.ExamPaper{}).Error
|
||||
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error
|
||||
_ = db.Delete(&room).Error
|
||||
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 {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
if !internalDb.ExamRoomPrepEditable(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"})
|
||||
ds := internalDb.ExamDisplayStatus(room, time.Now())
|
||||
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
|
||||
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"`
|
||||
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"`
|
||||
CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"`
|
||||
}
|
||||
|
||||
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/unpublish", handlers.UnpublishExamRoomHandler(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-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
|
||||
|
||||
Reference in New Issue
Block a user