clean ui
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||
import { GitHubReposPanel } from './GitHubReposPanel';
|
||||
import { OrganizationSection } from './OrganizationSection';
|
||||
|
||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||
const { changePassword, logout } = useAuth();
|
||||
@@ -67,62 +67,12 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||
}
|
||||
|
||||
export function EmailDomainsTab() {
|
||||
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
const res = await apiAdmin.listEmailDomains();
|
||||
setDomains(res.data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const addDomain = async () => {
|
||||
if (!newDomain.trim()) return;
|
||||
await apiAdmin.addEmailDomain(newDomain.trim());
|
||||
setNewDomain('');
|
||||
await load();
|
||||
};
|
||||
|
||||
const removeDomain = async (id: number) => {
|
||||
if (!confirm('Xóa đuôi email này?')) return;
|
||||
await apiAdmin.deleteEmailDomain(id);
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="tab-page">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">Đuôi email</h1>
|
||||
<p className="page-desc">Cấu hình đuôi email được phép đăng ký và đăng nhập.</p>
|
||||
<h1 className="page-title">Quản lý tổ chức</h1>
|
||||
</header>
|
||||
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<h2 className="section-title">Đuôi email được phép</h2>
|
||||
<p className="page-desc" style={{ marginTop: 0 }}>
|
||||
Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<input
|
||||
placeholder="vd: rikkeiacademy.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={addDomain}>Thêm</button>
|
||||
</div>
|
||||
<ul className="domain-list">
|
||||
{domains.length === 0 && <li className="domain-empty">Chưa cấu hình — chấp nhận tất cả đuôi email</li>}
|
||||
{domains.map((d) => (
|
||||
<li key={d.id} className="domain-item">
|
||||
<span>@{d.domain}</span>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>Xóa</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<OrganizationSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
131
management/src/components/AppTemplatePickerModal.tsx
Normal file
131
management/src/components/AppTemplatePickerModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||
import { countKeywords } from '../utils/appKeywords';
|
||||
|
||||
interface AppTemplatePickerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (keywords: string) => void;
|
||||
allowedApps: string;
|
||||
}
|
||||
|
||||
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
allowedApps,
|
||||
}) => {
|
||||
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await apiAppTemplates.list();
|
||||
setItems(res.data || []);
|
||||
} catch (e: any) {
|
||||
setItems([]);
|
||||
setError(e?.message || 'Không tải được khung ứng dụng');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description?.toLowerCase().includes(q) ||
|
||||
t.keywords.toLowerCase().includes(q),
|
||||
)
|
||||
: items;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ứng dụng</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Ghép bộ keyword đã lưu vào danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm theo tên, mô tả, keyword..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-picker-list">
|
||||
{filtered.map((tpl) => (
|
||||
<div key={tpl.id} className="template-picker-card">
|
||||
<div className="template-picker-card-head">
|
||||
<strong>{tpl.name}</strong>
|
||||
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
|
||||
</div>
|
||||
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
|
||||
<code className="template-picker-kw">{tpl.keywords}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm template-picker-apply"
|
||||
onClick={() => {
|
||||
onSelect(tpl.keywords);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Áp dụng khung
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-pool-footer">
|
||||
Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
management/src/components/AppTemplatesSection.tsx
Normal file
210
management/src/components/AppTemplatesSection.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
|
||||
|
||||
export function AppTemplatesSection() {
|
||||
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [poolOpen, setPoolOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAppTemplates.list();
|
||||
setItems(res.data || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true);
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setKeywords('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const openEdit = (tpl: AppTemplateItem) => {
|
||||
setEditing(tpl);
|
||||
setCreating(false);
|
||||
setName(tpl.name);
|
||||
setDescription(tpl.description || '');
|
||||
setKeywords(tpl.keywords);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Nhập tên khung.');
|
||||
return;
|
||||
}
|
||||
if (!keywords.trim()) {
|
||||
setError('Thêm ít nhất một keyword.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAppTemplates.update(editing.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
keywords,
|
||||
});
|
||||
} else {
|
||||
await apiAppTemplates.create({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
keywords,
|
||||
});
|
||||
}
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Lỗi lưu khung');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (tpl: AppTemplateItem) => {
|
||||
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
|
||||
try {
|
||||
await apiAppTemplates.delete(tpl.id);
|
||||
if (editing?.id === tpl.id) closeForm();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Không xóa được');
|
||||
}
|
||||
};
|
||||
|
||||
const addPoolKeyword = (kw: string) => {
|
||||
setKeywords((prev) => mergeKeywordCSV(prev, kw));
|
||||
};
|
||||
|
||||
const showForm = creating || editing;
|
||||
|
||||
return (
|
||||
<div className="system-section">
|
||||
<div className="system-block">
|
||||
<div className="system-block-head">
|
||||
<div>
|
||||
<h2 className="system-block-title">Khung ứng dụng</h2>
|
||||
<p className="system-block-desc">
|
||||
Tạo bộ keyword whitelist dùng chung — ghép từ kho app hoặc tự nhập. Lớp học và phòng thi có thể áp dụng nhanh thay vì chỉ chọn từng app trong kho.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||
+ Tạo khung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="system-form-card">
|
||||
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
|
||||
<div className="system-form-grid">
|
||||
<label className="login-field">
|
||||
<span>Tên khung</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Java IDE, Thi cuối kỳ..."
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field system-form-full">
|
||||
<span>Mô tả (tuỳ chọn)</span>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Ghi chú ngắn cho giáo viên"
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field system-form-full">
|
||||
<span>Keywords (phân tách bằng dấu phẩy)</span>
|
||||
<textarea
|
||||
className="app-textarea"
|
||||
rows={4}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder="chrome, vscode, cursor, goland, client"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="system-form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||
Thêm từ kho app
|
||||
</button>
|
||||
<div className="system-form-actions-right">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeForm}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||
{busy ? 'Đang lưu...' : 'Lưu khung'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="system-empty">
|
||||
Chưa có khung nào. Bấm <strong>Tạo khung</strong> để ghép keyword từ kho hoặc tự ghi.
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-grid">
|
||||
{items.map((tpl) => (
|
||||
<article key={tpl.id} className="template-card">
|
||||
<div className="template-card-head">
|
||||
<h3>{tpl.name}</h3>
|
||||
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
|
||||
</div>
|
||||
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
|
||||
<code className="template-card-kw">{tpl.keywords}</code>
|
||||
<div className="template-card-actions">
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
|
||||
Sửa
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppPoolModal
|
||||
open={poolOpen}
|
||||
onClose={() => setPoolOpen(false)}
|
||||
onSelect={addPoolKeyword}
|
||||
allowedApps={keywords}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { AttendancePanel } from './AttendancePanel';
|
||||
import { StudentAvatar } from './StudentAvatar';
|
||||
import { StudentDetailModal } from './StudentDetailModal';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||
|
||||
interface ClassWorkspaceProps {
|
||||
classId: number;
|
||||
@@ -52,6 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
|
||||
const loadAllData = async () => {
|
||||
try {
|
||||
@@ -177,6 +180,10 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
});
|
||||
};
|
||||
|
||||
const applyTemplate = (keywords: string) => {
|
||||
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
|
||||
};
|
||||
|
||||
const handleSaveAllowedApps = async () => {
|
||||
try {
|
||||
setSavingApps(true);
|
||||
@@ -329,18 +336,27 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
</div>
|
||||
<div className="app-pool-open-row">
|
||||
<div>
|
||||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
||||
<span className="config-suggestions-label">Kho & khung ứng dụng</span>
|
||||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
||||
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary app-pool-open-btn"
|
||||
onClick={() => setAppPoolOpen(true)}
|
||||
>
|
||||
Mở kho & tìm kiếm
|
||||
</button>
|
||||
<div className="app-pool-open-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary app-pool-open-btn"
|
||||
onClick={() => setTemplatePickerOpen(true)}
|
||||
>
|
||||
Chọn khung
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary app-pool-open-btn"
|
||||
onClick={() => setAppPoolOpen(true)}
|
||||
>
|
||||
Mở kho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
@@ -539,6 +555,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
||||
onSelect={addAppKeyword}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
<AppTemplatePickerModal
|
||||
open={templatePickerOpen}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
onSelect={applyTemplate}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import { navigate, pushNav } from '../navigation';
|
||||
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
|
||||
import { NavHistoryBar } from './NavHistoryBar';
|
||||
import { StudentAvatar } from './StudentAvatar';
|
||||
@@ -75,6 +77,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -177,6 +180,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const applyTemplate = (keywords: string) => {
|
||||
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
|
||||
};
|
||||
|
||||
const saveRoom = async () => {
|
||||
setErr(''); setMsg('');
|
||||
setSaving(true);
|
||||
@@ -623,14 +630,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
<div className="app-pool-open-row">
|
||||
<div>
|
||||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
||||
<span className="config-suggestions-label">Kho & khung ứng dụng</span>
|
||||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
||||
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||||
Mở kho & tìm kiếm
|
||||
</button>
|
||||
<div className="app-pool-open-actions">
|
||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setTemplatePickerOpen(true)}>
|
||||
Chọn khung
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||||
Mở kho
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
||||
@@ -1221,6 +1233,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
onSelect={addAppKeyword}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
<AppTemplatePickerModal
|
||||
open={templatePickerOpen}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
onSelect={applyTemplate}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { apiExam, type ExamRoomItem } from '../api';
|
||||
import { openExam } from './NavHistoryBar';
|
||||
|
||||
@@ -39,6 +39,7 @@ export const ExamsTab: React.FC = () => {
|
||||
const [start, setStart] = useState('');
|
||||
const [end, setEnd] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -52,7 +53,24 @@ export const ExamsTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = rooms.filter((r) => {
|
||||
const st = r.displayStatus || r.status;
|
||||
return st === 'ready' || st === 'draft';
|
||||
}).length;
|
||||
return { total: rooms.length, active, upcoming };
|
||||
}, [rooms]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rooms;
|
||||
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [rooms, search]);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !start || !end) return;
|
||||
@@ -65,6 +83,9 @@ export const ExamsTab: React.FC = () => {
|
||||
});
|
||||
setShowCreate(false);
|
||||
setName('');
|
||||
setStart('');
|
||||
setEnd('');
|
||||
await load();
|
||||
openExam(room.id, room.name);
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Lỗi');
|
||||
@@ -74,20 +95,47 @@ export const ExamsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="tab-page exams-page">
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<h1 className="page-title">Phòng thi</h1>
|
||||
<p className="page-desc">Tạo phòng thi, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button>
|
||||
<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">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="exams-toolbar">
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<h2 className="section-title">Phòng thi mới</h2>
|
||||
<div className="form-grid" style={{ maxWidth: 520 }}>
|
||||
<label className="login-field">
|
||||
<div className="system-form-card exams-create-card">
|
||||
<h2 className="system-form-title">Phòng thi mới</h2>
|
||||
<div className="system-form-grid">
|
||||
<label className="login-field system-form-full">
|
||||
<span>Tên phòng thi</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
|
||||
</label>
|
||||
@@ -100,48 +148,56 @@ export const ExamsTab: React.FC = () => {
|
||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
|
||||
<div className="system-form-actions">
|
||||
<div />
|
||||
<div className="system-form-actions-right">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
|
||||
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card table-card">
|
||||
{loading ? (
|
||||
<div className="table-empty">Đang tải...</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<div className="table-empty">Chưa có phòng thi nào.</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tên</th>
|
||||
<th>Thời gian</th>
|
||||
<th>SV / Đề</th>
|
||||
<th>Trạng thái</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td><strong>{r.name}</strong></td>
|
||||
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} — {fmtTime(r.endTime)}</td>
|
||||
<td>{r.studentCount} SV · {r.paperCount} đề</td>
|
||||
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
|
||||
<td>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
) : (
|
||||
<div className="exams-card-grid">
|
||||
{filtered.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
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>
|
||||
<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>
|
||||
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}>
|
||||
Mở phòng thi
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
TAB_LABELS,
|
||||
SYSTEM_SECTION_LABELS,
|
||||
type NavEntry,
|
||||
type TabId,
|
||||
parseRoute,
|
||||
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
return () => window.removeEventListener('popstate', refresh);
|
||||
}, []);
|
||||
|
||||
const currentTabLabel = TAB_LABELS[route.tab];
|
||||
const currentTabLabel =
|
||||
route.tab === 'system'
|
||||
? `Hệ thống · ${SYSTEM_SECTION_LABELS[route.systemSection]}`
|
||||
: TAB_LABELS[route.tab];
|
||||
const recent = history.slice(0, 6);
|
||||
|
||||
const handlePillClick = (entry: NavEntry) => {
|
||||
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
navigate(entry.tab, entry.classId, entry.label, 'class');
|
||||
} else if (entry.kind === 'exam' && entry.examId) {
|
||||
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
||||
} else if (entry.tab === 'system' && entry.systemSection) {
|
||||
navigate('system', null, undefined, 'class', entry.systemSection);
|
||||
} else {
|
||||
navigate(entry.tab);
|
||||
}
|
||||
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
if (entry.kind === 'exam' && route.examId) {
|
||||
return entry.examId === route.examId;
|
||||
}
|
||||
if (entry.kind === 'tab' && route.tab === 'system') {
|
||||
return !route.classId && !route.examId && entry.tab === 'system' && entry.systemSection === route.systemSection;
|
||||
}
|
||||
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
||||
};
|
||||
|
||||
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
||||
Simple Care
|
||||
</button>
|
||||
<span className="breadcrumb-sep">/</span>
|
||||
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}>
|
||||
<button type="button" className="breadcrumb-link" onClick={() => {
|
||||
if (route.tab === 'system') {
|
||||
navigate('system', null, undefined, 'class', route.systemSection);
|
||||
} else {
|
||||
navigate(route.tab);
|
||||
}
|
||||
}}>
|
||||
{currentTabLabel}
|
||||
</button>
|
||||
{route.classId && classLabel && (
|
||||
|
||||
@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
|
||||
return key.match(/.{2}/g)!.join(':');
|
||||
}
|
||||
|
||||
export const NetworkTab: React.FC = () => {
|
||||
export const NetworkSection: React.FC = () => {
|
||||
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
|
||||
const res = await apiFetchAcceptedWifis();
|
||||
setAcceptedItems(
|
||||
(res.data || [])
|
||||
.filter(r => r.ssid && r.bssid)
|
||||
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
|
||||
.filter((r) => r.ssid && r.bssid)
|
||||
.map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
|
||||
);
|
||||
} catch {
|
||||
setAcceptedItems([]);
|
||||
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
|
||||
const trimmedBssid = bssid.trim();
|
||||
const key = bssidKey(trimmedBssid);
|
||||
if (!trimmedSsid || key.length !== 12) return;
|
||||
setAcceptedItems(prev => {
|
||||
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
|
||||
setAcceptedItems((prev) => {
|
||||
if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
|
||||
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeWifi = (bssid: string) => {
|
||||
const key = bssidKey(bssid);
|
||||
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
|
||||
setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
|
||||
};
|
||||
|
||||
const handleManualAdd = () => {
|
||||
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const key = bssidKey(bssid);
|
||||
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) {
|
||||
if (acceptedItems.some((item) => bssidKey(item.bssid) === key)) {
|
||||
setManualError('BSSID này đã có trong danh sách.');
|
||||
return;
|
||||
}
|
||||
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
|
||||
setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
|
||||
setManualSsid('');
|
||||
setManualBssid('');
|
||||
setManualError(null);
|
||||
@@ -96,41 +96,36 @@ export const NetworkTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(',');
|
||||
const acceptedBssidKeys = acceptedItems.map((item) => bssidKey(item.bssid)).join(',');
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">Quản lý mạng</h1>
|
||||
<p className="page-subtitle">
|
||||
Cấu hình điểm phát WiFi được phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>.
|
||||
Sinh viên không thể giả mạo bằng hotspot trùng tên.
|
||||
</p>
|
||||
<div className="system-section system-section--grid">
|
||||
<div className="system-block">
|
||||
<div className="system-block-head">
|
||||
<div>
|
||||
<h2 className="system-block-title">Gói mạng được chấp nhận</h2>
|
||||
<p className="system-block-desc">
|
||||
SSID + BSSID (MAC điểm phát) áp dụng toàn hệ thống. Hotspot giả trùng tên nhưng MAC khác sẽ bị chặn.
|
||||
</p>
|
||||
</div>
|
||||
<span className="system-stat-pill">{acceptedItems.length} điểm phát</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="network-grid">
|
||||
<div className="card config-card-flat">
|
||||
<div className="card-header-title">WiFi được chấp nhận</div>
|
||||
<p className="config-card-desc">
|
||||
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Để trống = chưa bật kiểm tra WiFi.
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="network-manual-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleManualAdd();
|
||||
}}
|
||||
>
|
||||
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
|
||||
<form
|
||||
className="network-manual-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleManualAdd();
|
||||
}}
|
||||
>
|
||||
<p className="network-manual-hint">Thêm thủ công (router, lệnh netsh...)</p>
|
||||
<div className="system-inline-form system-inline-form--stack">
|
||||
<input
|
||||
type="text"
|
||||
className="app-pool-search"
|
||||
placeholder="SSID (tên WiFi)"
|
||||
value={manualSsid}
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
setManualSsid(e.target.value);
|
||||
setManualError(null);
|
||||
}}
|
||||
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
|
||||
className="app-pool-search"
|
||||
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
|
||||
value={manualBssid}
|
||||
onChange={e => {
|
||||
onChange={(e) => {
|
||||
setManualBssid(e.target.value);
|
||||
setManualError(null);
|
||||
}}
|
||||
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
|
||||
<button type="submit" className="btn btn-secondary" disabled={loading}>
|
||||
+ Thêm thủ công
|
||||
</button>
|
||||
{manualError && <p className="network-manual-error">{manualError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
{manualError && <p className="network-manual-error">{manualError}</p>}
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
) : acceptedItems.length === 0 ? (
|
||||
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
|
||||
Chưa có điểm phát nào. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : acceptedItems.length === 0 ? (
|
||||
<div className="system-empty system-empty--muted">
|
||||
Chưa có điểm phát. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
||||
</div>
|
||||
) : (
|
||||
<div className="system-table-wrap">
|
||||
<table className="data-table app-pool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SSID</th>
|
||||
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{acceptedItems.map(item => (
|
||||
{acceptedItems.map((item) => (
|
||||
<tr key={bssidKey(item.bssid)}>
|
||||
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
||||
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary app-pool-add-btn"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => removeWifi(item.bssid)}
|
||||
>
|
||||
Xóa
|
||||
@@ -189,29 +186,28 @@ export const NetworkTab: React.FC = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="network-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||
Mở kho WiFi
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card config-card-flat network-info-card">
|
||||
<div className="card-header-title">Cách hoạt động</div>
|
||||
<ul className="network-info-list">
|
||||
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li>
|
||||
<li>Thầy cô chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
|
||||
<li>Hotspot giả trùng tên nhưng MAC khác → app báo lỗi và thoát</li>
|
||||
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
||||
</ul>
|
||||
<div className="network-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||
Mở kho WiFi
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="system-block system-block--info">
|
||||
<h3 className="system-info-title">Cách hoạt động</h3>
|
||||
<ul className="network-info-list">
|
||||
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> lên kho</li>
|
||||
<li>Chọn từ kho hoặc thêm thủ công SSID + BSSID</li>
|
||||
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<WifiPoolModal
|
||||
open={poolOpen}
|
||||
onClose={() => setPoolOpen(false)}
|
||||
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** @deprecated Dùng SystemTab → Gói mạng */
|
||||
export const NetworkTab: React.FC = () => (
|
||||
<div className="tab-page">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">Quản lý mạng</h1>
|
||||
</header>
|
||||
<NetworkSection />
|
||||
</div>
|
||||
);
|
||||
103
management/src/components/OrganizationSection.tsx
Normal file
103
management/src/components/OrganizationSection.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||
|
||||
export function OrganizationSection() {
|
||||
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
||||
const [newDomain, setNewDomain] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdmin.listEmailDomains();
|
||||
setDomains(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const addDomain = async () => {
|
||||
if (!newDomain.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiAdmin.addEmailDomain(newDomain.trim());
|
||||
setNewDomain('');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Không thêm được');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDomain = async (id: number) => {
|
||||
if (!confirm('Xóa đuôi email này?')) return;
|
||||
try {
|
||||
await apiAdmin.deleteEmailDomain(id);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Không xóa được');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="system-section">
|
||||
<div className="system-block">
|
||||
<div className="system-block-head">
|
||||
<div>
|
||||
<h2 className="system-block-title">Đuôi email được phép</h2>
|
||||
<p className="system-block-desc">
|
||||
Giới hạn tài khoản giáo viên / nhân sự theo tổ chức. Chưa cấu hình = chấp nhận mọi đuôi email.
|
||||
</p>
|
||||
</div>
|
||||
<span className="system-stat-pill">{domains.length} đuôi</span>
|
||||
</div>
|
||||
|
||||
<div className="system-inline-form">
|
||||
<input
|
||||
placeholder="vd: rikkeiacademy.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={addDomain} disabled={busy}>
|
||||
Thêm đuôi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : domains.length === 0 ? (
|
||||
<div className="system-empty system-empty--muted">
|
||||
Chưa giới hạn đuôi email — mọi địa chỉ đều có thể đăng ký.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="system-domain-list">
|
||||
{domains.map((d) => (
|
||||
<li key={d.id} className="system-domain-item">
|
||||
<span className="system-domain-label">@{d.domain}</span>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>
|
||||
Xóa
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="system-block system-block--info">
|
||||
<h3 className="system-info-title">Gợi ý</h3>
|
||||
<ul className="network-info-list">
|
||||
<li>Thêm từng đuôi email thuộc tổ chức (vd: <code>rikkei.edu.vn</code>)</li>
|
||||
<li>Sinh viên đăng nhập client không bị ảnh hưởng bởi cấu hình này</li>
|
||||
<li>Chỉ áp dụng cho tài khoản quản lý trên portal này</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
management/src/components/SystemTab.tsx
Normal file
92
management/src/components/SystemTab.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
navigateSystem,
|
||||
parseRoute,
|
||||
SYSTEM_SECTION_LABELS,
|
||||
type SystemSection,
|
||||
} from '../navigation';
|
||||
import { OrganizationSection } from './OrganizationSection';
|
||||
import { NetworkSection } from './NetworkSection';
|
||||
import { AppTemplatesSection } from './AppTemplatesSection';
|
||||
|
||||
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
|
||||
{
|
||||
id: 'organization',
|
||||
hint: 'Đuôi email & tổ chức',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
hint: 'WiFi được phép',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<circle cx="12" cy="20" r="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
hint: 'Bộ keyword app',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const SystemTab: React.FC = () => {
|
||||
const [section, setSection] = useState<SystemSection>(() => parseRoute().systemSection);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setSection(parseRoute().systemSection);
|
||||
window.addEventListener('popstate', sync);
|
||||
return () => window.removeEventListener('popstate', sync);
|
||||
}, []);
|
||||
|
||||
const selectSection = (id: SystemSection) => {
|
||||
setSection(id);
|
||||
navigateSystem(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-page system-page">
|
||||
<header className="page-header">
|
||||
<h1 className="page-title">Quản lý hệ thống</h1>
|
||||
<p className="page-desc">
|
||||
Cấu hình tổ chức, mạng WiFi và khung ứng dụng dùng chung cho toàn hệ thống.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav className="system-subnav" aria-label="Mục hệ thống">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`system-subnav-btn ${section === s.id ? 'active' : ''}`}
|
||||
onClick={() => selectSection(s.id)}
|
||||
>
|
||||
<span className="system-subnav-icon">{s.icon}</span>
|
||||
<span className="system-subnav-text">
|
||||
<strong>{SYSTEM_SECTION_LABELS[s.id]}</strong>
|
||||
<small>{s.hint}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="system-panel">
|
||||
{section === 'organization' && <OrganizationSection />}
|
||||
{section === 'network' && <NetworkSection />}
|
||||
{section === 'templates' && <AppTemplatesSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user