tt (#1)
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
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>
This commit is contained in:
@@ -11,13 +11,13 @@ function fmtTime(iso: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
|
||||
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
|
||||
return { text: st, cls: 'badge-muted' };
|
||||
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) {
|
||||
@@ -33,6 +33,64 @@ function localInputToISO(v: string) {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -52,7 +110,6 @@ export const ExamsTab: React.FC = () => {
|
||||
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) {
|
||||
@@ -112,7 +169,11 @@ export const ExamsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
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.`)) {
|
||||
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!');
|
||||
@@ -125,97 +186,216 @@ export const ExamsTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="tab-page exams-page">
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<h1 className="page-title">Danh sách phòng thi</h1>
|
||||
<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
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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 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="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 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>
|
||||
<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>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
||||
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">×</button>
|
||||
<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" style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Tên phòng thi</span>
|
||||
<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"
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Thi cuối kỳ Java"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Bắt đầu</span>
|
||||
<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"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Kết thúc</span>
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Kết thúc</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
/>
|
||||
@@ -226,7 +406,12 @@ export const ExamsTab: React.FC = () => {
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
|
||||
<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>
|
||||
@@ -234,85 +419,327 @@ export const ExamsTab: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<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>
|
||||
<span>{fmtTime(r.startTime)}</span>
|
||||
<span className="exam-list-card-sep">→</span>
|
||||
<span>{fmtTime(r.endTime)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="exam-list-card-label">Quy mô</span>
|
||||
<span>{r.studentCount} sinh viên · {r.paperCount} đề</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.25rem' }}>
|
||||
<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>
|
||||
{canDeleteRoom(r) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRoom(r.id, r.name);
|
||||
}}
|
||||
style={{
|
||||
borderColor: 'var(--danger)',
|
||||
color: 'var(--danger)',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontWeight: 600,
|
||||
transition: 'var(--transition)',
|
||||
margin: 0
|
||||
}}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user