All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
Reviewed-on: #1 Co-authored-by: Pham Hung <22.phamviethung.12a12@gmail.com> Co-committed-by: Pham Hung <22.phamviethung.12a12@gmail.com>
748 lines
25 KiB
TypeScript
748 lines
25 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { apiExam, type ExamRoomItem } from '../api';
|
|
import { openExam } from './NavHistoryBar';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
|
|
function fmtTime(iso: string) {
|
|
try {
|
|
return new Date(iso).toLocaleString('vi-VN');
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function statusMeta(st: string) {
|
|
if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
|
|
if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
|
|
if (st === 'active') return { text: 'Đang thi', tone: 'active' };
|
|
if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
|
|
if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
|
|
return { text: st, tone: 'muted' };
|
|
}
|
|
|
|
function toLocalInput(iso?: string) {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function localInputToISO(v: string) {
|
|
if (!v) return '';
|
|
return new Date(v).toISOString();
|
|
}
|
|
|
|
type RoomScope = 'mine' | 'all';
|
|
type IconProps = { size?: number; filled?: boolean };
|
|
|
|
const IconClipboard = ({ size = 22 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
|
|
<rect x="9" y="3" width="6" height="4" rx="1" />
|
|
<path d="M9 12h6M9 16h4" />
|
|
</svg>
|
|
);
|
|
|
|
const IconPlus = ({ size = 16 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
);
|
|
|
|
const IconStar = ({ size = 14, filled }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="m12 3.5 2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.8l-5.2 2.7 1-5.8-4.2-4.1 5.8-.8L12 3.5z" />
|
|
</svg>
|
|
);
|
|
|
|
const IconSearch = ({ size = 16 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
|
|
const IconCalendar = ({ size = 13 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<rect x="3" y="5" width="18" height="16" rx="2" />
|
|
<path d="M16 3v4M8 3v4M3 11h18" />
|
|
</svg>
|
|
);
|
|
|
|
const IconUsers = ({ size = 12 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="3.5" />
|
|
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
|
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
|
</svg>
|
|
);
|
|
|
|
const IconOpen = ({ size = 14 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M5 12h14" />
|
|
<path d="m13 6 6 6-6 6" />
|
|
</svg>
|
|
);
|
|
|
|
const IconInbox = ({ size = 36 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
</svg>
|
|
);
|
|
|
|
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);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [name, setName] = useState('');
|
|
const [start, setStart] = useState('');
|
|
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 {
|
|
const res = await apiExam.list();
|
|
setRooms(res.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
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 = 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: scopedRooms.length, active, upcoming };
|
|
}, [scopedRooms]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
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;
|
|
setBusy(true);
|
|
try {
|
|
const room = await apiExam.create({
|
|
name: name.trim(),
|
|
startTime: localInputToISO(start),
|
|
endTime: localInputToISO(end),
|
|
});
|
|
setShowCreate(false);
|
|
setName('');
|
|
setStart('');
|
|
setEnd('');
|
|
await load();
|
|
openExam(room.id, room.name);
|
|
} catch (e: any) {
|
|
alert(e?.message || 'Lỗi');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRoom = async (roomId: number, roomName: string) => {
|
|
if (
|
|
window.confirm(
|
|
`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`
|
|
)
|
|
) {
|
|
try {
|
|
await apiExam.remove(roomId);
|
|
alert('Đã xóa phòng thi thành công!');
|
|
await load();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Xóa phòng thi thất bại');
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="tab-page exams-page">
|
|
<div className="tab-page-toolbar">
|
|
<div className="page-header">
|
|
<div className="page-title">
|
|
<h1 className="page-title-heading">
|
|
<span className="page-title-icon" aria-hidden>
|
|
<IconClipboard />
|
|
</span>
|
|
Danh sách phòng thi
|
|
</h1>
|
|
<p>
|
|
{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 className="exams-header-actions">
|
|
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
|
<IconPlus />
|
|
Tạo phòng thi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="tab-page-body" style={{ gap: '0.85rem' }}>
|
|
<div className="exams-stats-row">
|
|
<div className="learning-stat">
|
|
<span className="learning-stat-value">{stats.total}</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>
|
|
<span className="learning-stat-label">Đang thi</span>
|
|
</div>
|
|
<div className="learning-stat">
|
|
<span className="learning-stat-value">{stats.upcoming}</span>
|
|
<span className="learning-stat-label">Sắp / tạm</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="content-card learning-toolbar-card exams-toolbar-card">
|
|
<div className="learning-toolbar exams-toolbar-inner">
|
|
<div className="learning-seg" role="group" aria-label="Phạm vi phòng thi">
|
|
<button
|
|
type="button"
|
|
className={`learning-seg-btn${roomScope === 'mine' ? ' learning-seg-btn--active' : ''}`}
|
|
onClick={() => setRoomScope('mine')}
|
|
>
|
|
<IconStar size={13} filled={roomScope === 'mine'} />
|
|
<span>Phòng của tôi</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`learning-seg-btn${roomScope === 'all' ? ' learning-seg-btn--active' : ''}`}
|
|
onClick={() => setRoomScope('all')}
|
|
>
|
|
<span>Xem tất cả</span>
|
|
</button>
|
|
</div>
|
|
<input
|
|
type="search"
|
|
className="app-pool-search learning-search exams-search"
|
|
placeholder="Tìm theo tên phòng thi..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="tab-page-scroll">
|
|
<div className="content-card" style={{ padding: 0 }}>
|
|
{loading ? (
|
|
<div className="exams-empty-panel">
|
|
<div className="sync-spinner" style={{ width: 36, height: 36, borderWidth: 3 }} />
|
|
<p>Đang tải danh sách phòng thi...</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="exams-empty-panel">
|
|
<div className="exams-empty-icon">
|
|
{search.trim() ? <IconSearch size={32} /> : <IconInbox />}
|
|
</div>
|
|
<p>
|
|
{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() && (
|
|
<div className="exams-empty-actions">
|
|
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
|
<IconPlus />
|
|
Tạo phòng thi
|
|
</button>
|
|
{roomScope === 'mine' && (
|
|
<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 = statusMeta(r.displayStatus || r.status);
|
|
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
|
|
const isActive = (r.displayStatus || r.status) === 'active';
|
|
return (
|
|
<article
|
|
key={r.id}
|
|
className={`exam-list-card${isActive ? ' exam-list-card--active' : ''}`}
|
|
>
|
|
<header className="exam-list-card-head">
|
|
<div className="exam-list-card-info">
|
|
<h3 className="exam-list-card-title" title={r.name}>{r.name}</h3>
|
|
<div className="exam-list-card-meta-block">
|
|
<div className="exam-meta-row">
|
|
<IconCalendar />
|
|
<span>{fmtTime(r.startTime)}</span>
|
|
<span className="exam-list-card-sep">→</span>
|
|
<span>{fmtTime(r.endTime)}</span>
|
|
</div>
|
|
<div className="exam-meta-row exam-meta-row--secondary">
|
|
<IconUsers />
|
|
<span>
|
|
{r.studentCount} SV · {r.paperCount} đề
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="exam-list-card-badges">
|
|
{mine && <span className="exam-tag exam-tag--mine">Của tôi</span>}
|
|
<span className={`exam-tag exam-tag--${st.tone}`}>{st.text}</span>
|
|
</div>
|
|
</header>
|
|
|
|
<footer className="exam-list-card-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={() => openExam(r.id, r.name)}
|
|
>
|
|
Mở phòng thi
|
|
<IconOpen />
|
|
</button>
|
|
{canDeleteRoom(r) && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm exam-btn-danger"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteRoom(r.id, r.name);
|
|
}}
|
|
>
|
|
Xóa
|
|
</button>
|
|
)}
|
|
</footer>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
|
<div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header class-picker-header">
|
|
<div>
|
|
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
|
<p className="class-picker-subtitle">
|
|
Đặt tên và khung giờ thi. Sau khi tạo sẽ mở workspace để cấu hình đề và sinh viên.
|
|
</p>
|
|
</div>
|
|
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
|
|
×
|
|
</button>
|
|
</div>
|
|
<div className="modal-body class-picker-body exam-create-body">
|
|
<label className="exam-field">
|
|
<span className="exam-field-label">Tên phòng thi</span>
|
|
<input
|
|
className="search-input"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="VD: Thi cuối kỳ Java"
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<div className="exam-field-grid">
|
|
<label className="exam-field">
|
|
<span className="exam-field-label">Bắt đầu</span>
|
|
<input
|
|
type="datetime-local"
|
|
className="search-input exam-datetime"
|
|
value={start}
|
|
onChange={(e) => setStart(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="exam-field">
|
|
<span className="exam-field-label">Kết thúc</span>
|
|
<input
|
|
type="datetime-local"
|
|
className="search-input exam-datetime"
|
|
value={end}
|
|
onChange={(e) => setEnd(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer" style={{ gap: '0.65rem' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={busy || !name.trim() || !start || !end}
|
|
onClick={create}
|
|
>
|
|
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.page-title-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
}
|
|
.page-title-icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.exams-header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
}
|
|
.exams-header-actions .btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.exams-toolbar-card {
|
|
margin-bottom: 0 !important;
|
|
}
|
|
.exams-toolbar-inner {
|
|
margin: 0;
|
|
}
|
|
.exams-search {
|
|
max-width: none !important;
|
|
}
|
|
|
|
.exams-empty-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.65rem;
|
|
padding: 2.75rem 1.25rem;
|
|
text-align: center;
|
|
}
|
|
.exams-empty-panel p {
|
|
margin: 0;
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
max-width: 36ch;
|
|
line-height: 1.45;
|
|
}
|
|
.exams-empty-icon {
|
|
color: var(--text-muted);
|
|
display: flex;
|
|
}
|
|
.exams-empty-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.exams-empty-actions .btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.exams-card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
gap: 0.75rem;
|
|
padding: 0.85rem;
|
|
padding-bottom: 4.75rem;
|
|
}
|
|
|
|
.exam-list-card {
|
|
background: #fff;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
padding: 0.85rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
box-shadow: var(--shadow-sm);
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
min-height: 0;
|
|
}
|
|
.exam-list-card:hover {
|
|
border-color: #c5cdd8;
|
|
box-shadow: 0 3px 12px rgba(26, 35, 50, 0.08);
|
|
}
|
|
.exam-list-card--active {
|
|
border-left: 3px solid var(--accent);
|
|
background: rgba(187, 33, 38, 0.03);
|
|
}
|
|
|
|
.exam-list-card-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
flex: 1;
|
|
}
|
|
.exam-list-card-info {
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
.exam-list-card-title {
|
|
margin: 0 0 0.4rem;
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
line-height: 1.35;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.exam-list-card-meta-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
.exam-meta-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.35rem;
|
|
font-size: 0.7rem;
|
|
color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
flex-wrap: wrap;
|
|
}
|
|
.exam-meta-row svg {
|
|
flex-shrink: 0;
|
|
color: var(--text-muted);
|
|
margin-top: 0.1rem;
|
|
}
|
|
.exam-meta-row--secondary {
|
|
color: var(--text-muted);
|
|
font-size: 0.68rem;
|
|
}
|
|
.exam-list-card-sep {
|
|
color: var(--text-muted);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.exam-list-card-badges {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 0.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.exam-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.15rem 0.45rem;
|
|
border-radius: 999px;
|
|
font-size: 0.62rem;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
border: 1px solid transparent;
|
|
}
|
|
.exam-tag--mine {
|
|
background: rgba(234, 179, 8, 0.14);
|
|
color: #a16207;
|
|
border-color: rgba(234, 179, 8, 0.35);
|
|
}
|
|
.exam-tag--active {
|
|
background: rgba(13, 159, 110, 0.12);
|
|
color: #0b7a55;
|
|
border-color: rgba(13, 159, 110, 0.28);
|
|
}
|
|
.exam-tag--info {
|
|
background: rgba(37, 99, 235, 0.1);
|
|
color: #1d4ed8;
|
|
border-color: rgba(37, 99, 235, 0.22);
|
|
}
|
|
.exam-tag--warn {
|
|
background: rgba(245, 158, 11, 0.12);
|
|
color: #b45309;
|
|
border-color: rgba(245, 158, 11, 0.3);
|
|
}
|
|
.exam-tag--muted {
|
|
background: var(--bg-subtle);
|
|
color: var(--text-muted);
|
|
border-color: var(--border-color);
|
|
}
|
|
|
|
.exam-list-card-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
margin-top: auto;
|
|
padding-top: 0.55rem;
|
|
border-top: 1px solid var(--border-light);
|
|
}
|
|
.exam-list-card-actions .btn {
|
|
flex: 1;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
height: 30px;
|
|
padding: 0.35rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
border-radius: 7px;
|
|
line-height: 1;
|
|
}
|
|
.exam-btn-danger {
|
|
color: #b91c1c !important;
|
|
border-color: #fecaca !important;
|
|
background: #fef2f2 !important;
|
|
flex: 0 0 auto !important;
|
|
min-width: 52px;
|
|
}
|
|
.exam-btn-danger:hover {
|
|
background: #fee2e2 !important;
|
|
border-color: #fca5a5 !important;
|
|
}
|
|
|
|
.exam-create-modal {
|
|
max-width: 520px !important;
|
|
}
|
|
.exam-create-modal .class-picker-header {
|
|
align-items: flex-start !important;
|
|
padding: 1.1rem 1.35rem !important;
|
|
}
|
|
.exam-create-modal .class-picker-subtitle {
|
|
margin: 0.3rem 0 0;
|
|
color: var(--text-muted);
|
|
font-size: 0.78rem;
|
|
line-height: 1.4;
|
|
max-width: 42ch;
|
|
}
|
|
.exam-create-modal .class-picker-body {
|
|
padding: 1rem 1.35rem 1.15rem !important;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
.exam-create-body {
|
|
gap: 0.9rem !important;
|
|
}
|
|
.exam-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.exam-field-label {
|
|
font-weight: 600;
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
.exam-field .search-input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
.exam-field-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.75rem;
|
|
}
|
|
.exam-datetime {
|
|
padding: 0.5rem 0.75rem !important;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.exams-header-actions {
|
|
width: 100%;
|
|
}
|
|
.exams-header-actions .btn {
|
|
flex: 1;
|
|
justify-content: center;
|
|
}
|
|
.page-header {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 0.75rem;
|
|
}
|
|
.learning-seg {
|
|
width: 100%;
|
|
}
|
|
.learning-seg-btn {
|
|
flex: 1;
|
|
}
|
|
.exams-stats-row {
|
|
grid-template-columns: repeat(3, 1fr) !important;
|
|
gap: 0.45rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.exams-card-grid {
|
|
grid-template-columns: 1fr;
|
|
padding: 0.75rem;
|
|
gap: 0.65rem;
|
|
}
|
|
.exam-list-card-actions .btn {
|
|
height: 32px;
|
|
font-size: 0.75rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
.exams-stats-row {
|
|
grid-template-columns: 1fr !important;
|
|
}
|
|
.exam-field-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.exam-list-card-actions {
|
|
flex-direction: column;
|
|
}
|
|
.exam-btn-danger {
|
|
width: 100%;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { toLocalInput, localInputToISO, fmtTime };
|