add tai lieu system
All checks were successful
Deploy on Master Change / deploy (push) Successful in 43s

This commit is contained in:
2026-07-01 14:47:46 +07:00
parent 6919513165
commit dd766206f7
15 changed files with 952 additions and 140 deletions

1
management/src/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=https://sv.rikkeiraia.org/api

View File

@@ -7,6 +7,7 @@ import { ExamsTab } from './components/ExamsTab';
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab';
import { StudentAffairsTab } from './components/StudentAffairsTab';
import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace';
@@ -68,6 +69,12 @@ const IconSystem = () => (
</svg>
);
const IconStudentAffairs = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
);
const IconMenu = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
@@ -202,6 +209,17 @@ function App() {
</button>
</li>
<div className="nav-header">Công tác sinh viên</div>
<li className="nav-item">
<button
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
onClick={() => setActiveTab('student-affairs')}
>
<span className="nav-icon"><IconStudentAffairs /></span>
Công Tác Sinh Viên
</button>
</li>
<div className="nav-header">Hệ thống</div>
<li className="nav-item">
<button
@@ -267,6 +285,7 @@ function App() {
{route.tab === 'exams' && <ExamsTab />}
{route.tab === 'system' && <SystemTab />}
{route.tab === 'profile' && <MyAccountTab />}
{route.tab === 'student-affairs' && <StudentAffairsTab />}
</>
)}
</div>

View File

@@ -1,4 +1,4 @@
export const API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080/api';
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://sv.rikkeiraia.org/api';
export function getWsUrl(path: string): string {
try {
@@ -269,6 +269,14 @@ export interface StatsResponse {
totalStudents: number;
}
export interface GuideLinkItem {
id: number;
createdAt: string;
updatedAt: string;
title: string;
url: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
@@ -362,6 +370,33 @@ export const api = {
return res.json();
},
listGuideLinks: async (): Promise<{ data: GuideLinkItem[] }> => {
const res = await staffFetch('/guide-links');
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu');
return res.json();
},
createGuideLink: async (title: string, url: string): Promise<{ data: GuideLinkItem }> => {
const res = await staffFetch('/guide-links', {
method: 'POST',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Thêm tài liệu thất bại');
return res.json();
},
updateGuideLink: async (id: number, title: string, url: string): Promise<{ data: GuideLinkItem }> => {
const res = await staffFetch(`/guide-links/${id}`, {
method: 'PUT',
body: JSON.stringify({ title, url }),
});
if (!res.ok) await parseError(res, 'Cập nhật tài liệu thất bại');
return res.json();
},
deleteGuideLink: async (id: number): Promise<{ ok: boolean }> => {
const res = await staffFetch(`/guide-links/${id}`, { method: 'DELETE' });
if (!res.ok) await parseError(res, 'Xóa tài liệu thất bại');
return res.json();
},
getClasses: async (params: {
page: number;
pageSize: number;

View File

@@ -119,13 +119,19 @@ export const ClassesTab: React.FC = () => {
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner"></div> Đng bộ...
</>
) : (
'🔄 Đồng bộ Lớp học'
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
Đng bộ Lớp học
</>
)}
</button>
</div>
@@ -138,8 +144,24 @@ export const ClassesTab: React.FC = () => {
{syncStatus.running && <div className="sync-spinner"></div>}
<span>
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
{syncStatus.done && '🎉 Đồng bộ lớp học hoàn tất!'}
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
{syncStatus.done && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Đng bộ lớp học hoàn tất!
</span>
)}
{syncStatus.error && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Đng bộ thất bại: {syncStatus.error}
</span>
)}
</span>
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
@@ -171,7 +193,12 @@ export const ClassesTab: React.FC = () => {
setPage(1);
}}
/>
<span className="search-icon">🔍</span>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
</div>
<div className="filters-wrapper">
@@ -217,7 +244,12 @@ export const ClassesTab: React.FC = () => {
</div>
) : classes.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🏫</div>
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 2 3 6 3s6-1 6-3v-5" />
</svg>
</div>
<h2>Không tìm thấy lớp học nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ Lớp học" đ kéo dữ liệu mới.</p>
</div>
@@ -249,13 +281,21 @@ export const ClassesTab: React.FC = () => {
>
{cl.name}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}>
💼 {cl.specializeName || 'Chưa có chuyên ngành'}
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
{cl.specializeName || 'Chưa có chuyên ngành'}
</div>
</td>
<td>
<span className="badge badge-muted" style={{ fontWeight: 600 }}>
📍 {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
<span className="badge badge-muted" style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
</span>
</td>
<td>
@@ -272,8 +312,12 @@ export const ClassesTab: React.FC = () => {
</div>
</td>
<td>
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}>
👤 {cl.studentCount} SV
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{cl.studentCount} SV
</span>
</td>
<td>
@@ -304,7 +348,13 @@ export const ClassesTab: React.FC = () => {
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
onClick={() => openClass('classes', cl.rkId, cl.name)}
>
👥 Xem lớp
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Xem lớp
</button>
</td>
</tr>
@@ -327,8 +377,11 @@ export const ClassesTab: React.FC = () => {
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
@@ -337,8 +390,11 @@ export const ClassesTab: React.FC = () => {
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>

View File

@@ -1,16 +1,73 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api';
import { api, type GuideLinkItem } from '../api';
import type { StatsResponse } from '../api';
import { useAuth } from '../auth/AuthContext';
interface DashboardTabProps {
onNavigate: (tab: string) => void;
}
const IconClasses = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 2 3 6 3s6-1 6-3v-5" />
</svg>
);
const IconActiveClasses = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
const IconStudents = () => (
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const IconAlert = () => (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Guide links state
const [links, setLinks] = useState<GuideLinkItem[]>([]);
const [linksLoading, setLinksLoading] = useState(true);
// Add/Edit modal state
const [showModal, setShowModal] = useState(false);
const [editingLink, setEditingLink] = useState<GuideLinkItem | null>(null);
const [modalTitle, setModalTitle] = useState('');
const [modalUrl, setModalUrl] = useState('');
const [modalSaving, setModalSaving] = useState(false);
const fetchLinks = async () => {
try {
setLinksLoading(true);
const res = await api.listGuideLinks();
setLinks(res.data || []);
} catch (err) {
console.error('Không thể tải tài liệu hướng dẫn:', err);
} finally {
setLinksLoading(false);
}
};
useEffect(() => {
const fetchStats = async () => {
try {
@@ -24,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
}
};
fetchStats();
fetchLinks();
}, []);
const handleOpenAddModal = () => {
setEditingLink(null);
setModalTitle('');
setModalUrl('');
setShowModal(true);
};
const handleOpenEditModal = (link: GuideLinkItem) => {
setEditingLink(link);
setModalTitle(link.title);
setModalUrl(link.url);
setShowModal(true);
};
const handleSaveLink = async () => {
if (!modalTitle.trim() || !modalUrl.trim()) {
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn');
return;
}
try {
setModalSaving(true);
if (editingLink) {
await api.updateGuideLink(editingLink.id, modalTitle.trim(), modalUrl.trim());
} else {
await api.createGuideLink(modalTitle.trim(), modalUrl.trim());
}
setShowModal(false);
await fetchLinks();
} catch (err: any) {
alert(err.message || 'Lưu tài liệu thất bại');
} finally {
setModalSaving(false);
}
};
const handleDeleteLink = async (id: number, title: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu "${title}"?`)) {
try {
await api.deleteGuideLink(id);
await fetchLinks();
} catch (err: any) {
alert(err.message || 'Xóa tài liệu thất bại');
}
}
};
return (
<div className="tab-page">
<div className="tab-page-toolbar">
@@ -38,86 +142,249 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
</div>
<div className="tab-page-body tab-page-scroll">
{loading && (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
<p style={{ marginTop: '1rem' }}>Đang tải thông số hệ thống...</p>
</div>
)}
{error && (
<div className="empty-state" style={{ border: '1px solid rgba(239, 68, 68, 0.2)', backgroundColor: 'rgba(239, 68, 68, 0.05)' }}>
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}></div>
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
</div>
)}
{!loading && !error && stats && (
<>
<div className="stats-grid">
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Tổng lớp học đã đng bộ</div>
<div className="stat-value">{stats.totalClasses}</div>
</div>
<div className="stat-icon">🏫</div>
</div>
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Lớp đang giảng dạy</div>
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
</div>
<div className="stat-icon success"></div>
</div>
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Tổng sinh viên đã đng bộ</div>
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
</div>
<div className="stat-icon">🎓</div>
</div>
{loading && (
<div className="empty-state">
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
<p style={{ marginTop: '1rem' }}>Đang tải thông số hệ thống...</p>
</div>
)}
<div className="content-grid-2">
<div className="content-card">
<h2>Khởi đng nhanh</h2>
<p>
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
Hệ thống hỗ trợ đng bộ hóa thông tin tự đng từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học danh sách sinh viên nội bộ.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản lớp học
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
Danh sách sinh viên
</button>
{error && (
<div className="empty-state" style={{ border: '1px solid rgba(214, 48, 49, 0.2)', backgroundColor: 'rgba(214, 48, 49, 0.05)', borderRadius: '12px' }}>
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}>
<IconAlert />
</div>
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
</div>
)}
{!loading && !error && stats && (
<>
<div className="stats-grid">
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Tổng lớp học đã đng bộ</div>
<div className="stat-value">{stats.totalClasses}</div>
</div>
<div className="stat-icon" style={{ color: 'var(--accent)' }}>
<IconClasses />
</div>
</div>
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Lớp đang giảng dạy</div>
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
</div>
<div className="stat-icon success" style={{ color: 'var(--success)', backgroundColor: 'var(--success-light)' }}>
<IconActiveClasses />
</div>
</div>
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
<div>
<div className="stat-label">Tổng sinh viên đã đng bộ</div>
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
</div>
<div className="stat-icon" style={{ color: 'var(--accent-hover)' }}>
<IconStudents />
</div>
</div>
</div>
<div className="content-card">
<h2>Quy trình hoạt đng</h2>
<ul style={{
color: 'var(--text-secondary)',
fontSize: '0.875rem',
lineHeight: 1.75,
paddingLeft: '1.15rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
margin: 0
}}>
<li><strong>Đng bộ lớp học:</strong> Tải danh sách lớp môn học, kéo thông tin sinh viên từng lớp qua bảng kết quả.</li>
<li><strong>Quản lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/ lớp bật/tắt cờ <strong>Đang học</strong>.</li>
<li><strong>Đng bộ sinh viên:</strong> Kéo toàn bộ sinh viên từ hệ thống chính (kèm thông tin liên hệ, hệ học) vào database nội bộ.</li>
</ul>
<div style={{ marginTop: '1.5rem' }}>
<div className="content-card">
<h2>Khởi đng nhanh</h2>
<p style={{ lineHeight: '1.6', color: 'var(--text-secondary)' }}>
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
Hệ thống hỗ trợ quản lớp học, thi cử chăm sóc sinh viên.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản lớp học
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('learning')}>
Lịch học & Giám sát
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('exams')}>
Quản phòng thi
</button>
</div>
</div>
</div>
</div>
</>
)}
<div className="content-card" style={{ marginTop: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.75rem' }}>
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
Tài liệu hướng dẫn
</h2>
{isSuperAdmin && (
<button
className="btn btn-primary btn-sm"
onClick={handleOpenAddModal}
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
+ Thêm tài liệu
</button>
)}
</div>
{linksLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
<div className="sync-spinner" style={{ width: '24px', height: '24px' }}></div>
</div>
)}
{!linksLoading && links.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', fontSize: '0.9rem', margin: '0.5rem 0' }}>Chưa tài liệu hướng dẫn nào đưc cấu hình.</p>
)}
{!linksLoading && links.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{links.map((link) => (
<div
key={link.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 1rem',
backgroundColor: 'var(--bg-app)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
color: 'var(--text-primary)',
textDecoration: 'none',
fontWeight: 500,
fontSize: '0.92rem',
flex: 1
}}
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#4285F4' }}>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{link.title}
</a>
{isSuperAdmin && (
<div style={{ display: 'flex', gap: '6px' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenEditModal(link)}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
>
Sửa
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDeleteLink(link.id, link.title)}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
>
Xóa
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-container" style={{ maxWidth: '450px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{editingLink ? 'Cập nhật tài liệu' : 'Thêm tài liệu hướng dẫn'}</h2>
<button className="modal-close-btn" onClick={() => setShowModal(false)} aria-label="Đóng">&times;</button>
</div>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đ tài liệu</span>
<input
type="text"
value={modalTitle}
onChange={e => setModalTitle(e.target.value)}
placeholder="VD: Hướng dẫn coi thi cuối kỳ"
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đưng dẫn Google Docs</span>
<input
type="url"
value={modalUrl}
onChange={e => setModalUrl(e.target.value)}
placeholder="https://docs.google.com/document/d/..."
style={{
width: '100%',
padding: '0.55rem 0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontSize: '0.9rem',
outline: 'none',
transition: 'var(--transition)'
}}
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
/>
</label>
</div>
</div>
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Hủy</button>
<button
className="btn btn-primary"
onClick={handleSaveLink}
disabled={modalSaving}
>
{modalSaving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,7 @@
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 {
@@ -32,6 +33,9 @@ function localInputToISO(v: string) {
}
export const ExamsTab: React.FC = () => {
const { staff } = useAuth();
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
@@ -94,6 +98,18 @@ 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.`)) {
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">
<header className="page-header page-header--row">
@@ -190,9 +206,40 @@ export const ExamsTab: React.FC = () => {
<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>
<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>
{isSuperAdmin && (
<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
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--danger)';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--danger)';
}}
>
Xóa
</button>
)}
</div>
</article>
);
})}

View File

@@ -266,32 +266,41 @@ export const LearningTab: React.FC = () => {
<div className="content-card">
{loading && (
<div className="learning-empty">
<span className="learning-empty-icon">🔄</span>
<p>Đang tải...</p>
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải dữ liệu...</p>
</div>
)}
{!loading && activeClasses.length === 0 && (
<div className="learning-empty">
<span className="learning-empty-icon">📅</span>
<p>Chưa lớp nào đưc tạo lịch học.</p>
<button className="btn btn-secondary" onClick={handleOpenAddClass}>
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Chưa lớp nào đưc tạo lịch học.</p>
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '0.5rem' }}>
Bắt đu thêm lớp đu tiên
</button>
</div>
)}
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
<div className="learning-empty">
<span className="learning-empty-icon">🔍</span>
<p>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
<button
className="btn btn-secondary"
onClick={() => {
setSearchQuery('');
setDayFilter(null);
}}
style={{ marginTop: '0.5rem' }}
>
Xóa bộ lọc
</button>
@@ -385,8 +394,8 @@ export const LearningTab: React.FC = () => {
</div>
{configClass && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
<div className="modal-overlay" onClick={() => setConfigClass(null)}>
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title">Cấu hình lịch học & ng dụng</h2>
@@ -394,9 +403,9 @@ export const LearningTab: React.FC = () => {
{configClass.name} ({configClass.classCode})
</p>
</div>
<button className="btn btn-secondary close-btn" onClick={() => setConfigClass(null)}>Đóng</button>
<button className="modal-close-btn" onClick={() => setConfigClass(null)} aria-label="Đóng">&times;</button>
</div>
<div style={{ padding: '1rem 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div className="modal-body" style={{ padding: '1.75rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div style={{ borderRight: '1px solid var(--border-color)', paddingRight: '1.5rem' }}>
<h4 style={{ margin: '0 0 1rem 0' }}>Ca học trong tuần</h4>
<ScheduleEditor
@@ -412,11 +421,25 @@ export const LearningTab: React.FC = () => {
Từ khóa ngăn chặn các ng dụng ngoài luồng trên máy sinh viên.
</p>
<textarea
rows={6}
rows={8}
value={allowedApps}
onChange={e => setAllowedApps(e.target.value)}
placeholder="chrome,idea64,vscode,wails,simple_care"
className="app-textarea"
style={{
width: '100%',
padding: '0.75rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-color)',
fontFamily: 'monospace',
fontSize: '0.85rem',
color: 'var(--text-primary)',
backgroundColor: '#fff',
outline: 'none',
transition: 'var(--transition)',
resize: 'vertical'
}}
onFocus={(e) => e.target.style.borderColor = 'var(--accent)'}
onBlur={(e) => e.target.style.borderColor = 'var(--border-color)'}
/>
</div>
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
@@ -424,6 +447,9 @@ export const LearningTab: React.FC = () => {
</button>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setConfigClass(null)}>Đóng</button>
</div>
</div>
</div>
)}
@@ -500,33 +526,64 @@ export const LearningTab: React.FC = () => {
)}
{showAddClassModal && (
<div className="modal-overlay">
<div className="modal-container" style={{ maxWidth: '500px' }}>
<div className="modal-overlay" onClick={() => setShowAddClassModal(false)}>
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Chọn lớp học từ hệ thống</h2>
<button className="btn btn-secondary close-btn" onClick={() => setShowAddClassModal(false)}>Đóng</button>
<button className="modal-close-btn" onClick={() => setShowAddClassModal(false)} aria-label="Đóng">&times;</button>
</div>
<div style={{ padding: '1rem 0' }}>
<input
type="search"
className="app-pool-search"
placeholder="Tìm tên lớp hoặc mã lớp..."
value={searchClassQuery}
onChange={e => setSearchClassQuery(e.target.value)}
/>
<div style={{ maxHeight: '300px', overflowY: 'auto', marginTop: '1rem' }}>
<div className="modal-body" style={{ padding: '1.5rem' }}>
<div className="search-input-wrapper" style={{ width: '100%', marginBottom: '1.25rem' }}>
<input
type="search"
className="search-input"
placeholder="Tìm tên lớp hoặc mã lớp..."
value={searchClassQuery}
onChange={e => setSearchClassQuery(e.target.value)}
/>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
</div>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
{filteredAddClasses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp phù hợp.</p>
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Không tìm thấy lớp phù hợp.</p>
) : (
<ul className="learning-add-list">
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{filteredAddClasses.map(c => (
<li key={c.rkId} className="learning-add-item">
<li key={c.rkId} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.85rem 1rem',
backgroundColor: 'var(--bg-app)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-sm)',
transition: 'var(--transition)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
e.currentTarget.style.backgroundColor = '#fff';
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div>
<div className="learning-class-code">{c.classCode}</div>
<div className="learning-class-meta">{c.name}</div>
<div style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.9rem' }}>{c.classCode}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c.name}</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
Chọn
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)} style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
Kích hoạt
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
</li>
))}
@@ -534,6 +591,9 @@ export const LearningTab: React.FC = () => {
)}
</div>
</div>
<div className="modal-footer">
<button className="btn btn-secondary" onClick={() => setShowAddClassModal(false)}>Đóng</button>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,117 @@
import React, { useState } from 'react';
export const StudentAffairsTab: React.FC = () => {
const [showUnderDevModal, setShowUnderDevModal] = useState(false);
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>Công Tác Sinh Viên</h1>
<p>Quản các hoạt đng hỗ trợ, vấn chăm sóc sinh viên tại Rikkei Education</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1.5rem', marginTop: '0.5rem' }}>
<div
className="content-card"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
cursor: 'pointer',
transition: 'var(--transition)',
position: 'relative',
overflow: 'hidden',
padding: '1.75rem',
}}
onClick={() => setShowUnderDevModal(true)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = 'var(--shadow-lg)';
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--border-color)';
}}
>
<div>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '12px',
backgroundColor: 'var(--accent-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--accent)',
marginBottom: '1.25rem'
}}
>
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
</svg>
</div>
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>Chăm Sóc Sinh Viên</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
Hỗ trợ vấn, giải quyết các khó khăn, theo sát quá trình học tập đi sống tinh thần của học viên.
</p>
</div>
<div style={{ marginTop: '1.5rem', display: 'flex', alignItems: 'center', color: 'var(--accent)', fontWeight: 600, fontSize: '0.875rem' }}>
Truy cập chức năng
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '4px' }}>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
{showUnderDevModal && (
<div className="modal-overlay" style={{ zIndex: 1100 }} onClick={() => setShowUnderDevModal(false)}>
<div className="modal-container" style={{ maxWidth: '400px' }} onClick={e => e.stopPropagation()}>
<div className="modal-body" style={{ textAlign: 'center', padding: '2.5rem 2rem' }}>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
backgroundColor: 'rgba(230, 168, 23, 0.1)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--warning)',
marginBottom: '1.5rem'
}}
>
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2 style={{ fontSize: '1.3rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>Đang phát triển</h2>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '2rem' }}>
Tính năng <strong>Chăm Sóc Sinh Viên</strong> hiện đang đưc phát triển hoàn thiện. Vui lòng quay lại sau!
</p>
<button
type="button"
className="btn btn-primary"
onClick={() => setShowUnderDevModal(false)}
style={{ width: '100%', justifyContent: 'center' }}
>
Đng ý
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -99,13 +99,19 @@ export const StudentsTab: React.FC = () => {
className="btn btn-primary"
onClick={handleStartSync}
disabled={syncStatus?.running}
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
{syncStatus?.running ? (
<>
<div className="sync-spinner"></div> Đng bộ...
</>
) : (
'🔄 Đồng bộ toàn bộ SV'
<>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
Đng bộ toàn bộ SV
</>
)}
</button>
</div>
@@ -118,8 +124,24 @@ export const StudentsTab: React.FC = () => {
{syncStatus.running && <div className="sync-spinner"></div>}
<span>
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
{syncStatus.done && '🎉 Đồng bộ toàn bộ sinh viên hoàn tất!'}
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
{syncStatus.done && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Đng bộ toàn bộ sinh viên hoàn tất!
</span>
)}
{syncStatus.error && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Đng bộ thất bại: {syncStatus.error}
</span>
)}
</span>
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
@@ -151,7 +173,12 @@ export const StudentsTab: React.FC = () => {
setPage(1);
}}
/>
<span className="search-icon">🔍</span>
<span className="search-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
</div>
</div>
</div>
@@ -166,7 +193,14 @@ export const StudentsTab: React.FC = () => {
</div>
) : students.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">👥</div>
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<h2>Không sinh viên nào</h2>
<p>Hãy thử thay đi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" đ tải dữ liệu.</p>
</div>
@@ -192,12 +226,33 @@ export const StudentsTab: React.FC = () => {
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
</td>
<td>
<div>📧 {st.email}</div>
{st.phone && <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>📞 {st.phone}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
{st.email}
</div>
{st.phone && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
</svg>
{st.phone}
</div>
)}
</td>
<td>
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : ''}</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
</div>
</td>
@@ -232,8 +287,11 @@ export const StudentsTab: React.FC = () => {
className="pagination-btn"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
Trang {page} / {Math.ceil(total / pageSize) || 1}
@@ -242,8 +300,11 @@ export const StudentsTab: React.FC = () => {
className="pagination-btn"
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(total / pageSize)}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
export type SystemSection = 'organization' | 'network' | 'templates';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs';
export interface NavEntry {
id: string;
@@ -27,6 +27,7 @@ export const TAB_LABELS: Record<TabId, string> = {
exams: 'Phòng thi',
system: 'Hệ thống',
profile: 'Tài khoản của tôi',
'student-affairs': 'Công Tác Sinh Viên',
};
const HISTORY_KEY = 'sc_nav_history';
@@ -40,7 +41,8 @@ function isTabId(value: string | null): value is TabId {
value === 'learning' ||
value === 'exams' ||
value === 'system' ||
value === 'profile'
value === 'profile' ||
value === 'student-affairs'
);
}

View File

@@ -66,6 +66,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.StaffGitHubAuth{},
&models.StaffGitHubRepo{},
&models.SystemSetting{},
&models.GuideLink{},
); err != nil {
return err
}

View File

@@ -422,6 +422,11 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler {
// DELETE /api/exam-rooms/:id
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
email, _ := c.Locals("staffEmail").(string)
if email != "phuocntb@rikkeiacademy.com" {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
id, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})

View File

@@ -0,0 +1,124 @@
package handlers
import (
"strconv"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Helper check email
func isSuperAdmin(c *fiber.Ctx) bool {
email, ok := c.Locals("staffEmail").(string)
return ok && email == "phuocntb@rikkeiacademy.com"
}
// GET /api/guide-links
func ListGuideLinksHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var links []models.GuideLink
if err := db.Order("created_at desc").Find(&links).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu"})
}
return c.JSON(fiber.Map{"data": links})
}
}
// POST /api/guide-links
func CreateGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
link := models.GuideLink{
Title: req.Title,
URL: req.URL,
}
if err := db.Create(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Lưu tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": link})
}
}
// PUT /api/guide-links/:id
func UpdateGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var link models.GuideLink
if err := db.First(&link, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu"})
}
type Request struct {
Title string `json:"title"`
URL string `json:"url"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Title == "" || req.URL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
}
link.Title = req.Title
link.URL = req.URL
if err := db.Save(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": link})
}
}
// DELETE /api/guide-links/:id
func DeleteGuideLinkHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var link models.GuideLink
if err := db.First(&link, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu"})
}
if err := db.Delete(&link).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Xóa tài liệu thất bại"})
}
return c.JSON(fiber.Map{"ok": true})
}
}

View File

@@ -188,3 +188,15 @@ type SystemSetting struct {
func (SystemSetting) TableName() string { return "system_settings" }
// GuideLink đại diện cho tài liệu hướng dẫn Google Docs public
type GuideLink struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Title string `gorm:"column:title;size:512;not null" json:"title"`
URL string `gorm:"column:url;size:1024;not null" json:"url"`
}
func (GuideLink) TableName() string { return "guide_links" }

View File

@@ -202,6 +202,11 @@ func main() {
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
staff.Get("/guide-links", handlers.ListGuideLinksHandler(gormDB))
staff.Post("/guide-links", handlers.CreateGuideLinkHandler(gormDB))
staff.Put("/guide-links/:id", handlers.UpdateGuideLinkHandler(gormDB))
staff.Delete("/guide-links/:id", handlers.DeleteGuideLinkHandler(gormDB))
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()