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

View File

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

View File

@@ -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 đ 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,18 +237,38 @@ 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>
<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 className="exam-list-card-meta">
<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 }}>
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>

View File

@@ -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ị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>
<button className="btn btn-primary" onClick={handleOpenAddClass}>
+ Chọn lớp & Tạo lịch
</button>
</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 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' }}>
<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>
<button
className="btn btn-secondary"
onClick={() => {
setSearchQuery('');
setDayFilter(null);
setOnlyMyClasses(false);
}}
style={{ marginTop: '0.5rem' }}
>
Xóa bộ lọc
</button>
<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);
}}
>
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 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 && (
<LiveProctorModal
studentId={proctorStudent.id}

View File

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

View File

@@ -14,6 +14,7 @@ import (
"time"
internalDb "server/internal/db"
"server/internal/middleware"
"server/internal/models"
internalWs "server/internal/websocket"
@@ -120,15 +121,23 @@ 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 {
models.ExamRoom
StudentCount int `json:"studentCount"`
PaperCount int `json:"paperCount"`
DisplayStatus string `json:"displayStatus"`
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()
@@ -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.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error
out = append(out, row{
ExamRoom: r,
StudentCount: int(sc),
PaperCount: int(pc),
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
ExamRoom: r,
StudentCount: int(sc),
PaperCount: int(pc),
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
CreatedByStaffID: r.CreatedByStaffID,
})
}
return c.JSON(fiber.Map{"data": out})
@@ -177,12 +187,13 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
}
apps := strings.TrimSpace(req.AllowedApps)
room := models.ExamRoom{
Name: req.Name,
StartTime: start,
EndTime: end,
AllowedApps: apps,
QuizURL: strings.TrimSpace(req.QuizURL),
Status: models.ExamStatusDraft,
Name: req.Name,
StartTime: start,
EndTime: end,
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})

View File

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

View File

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