add tai lieu system
All checks were successful
Deploy on Master Change / deploy (push) Successful in 43s
All checks were successful
Deploy on Master Change / deploy (push) Successful in 43s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 và 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ý 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 và 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ý lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/mã lớp và 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ý lớp học, thi cử và 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ý 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 lý 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 có 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">×</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 có 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 có 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">×</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">×</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>
|
||||
)}
|
||||
|
||||
117
management/src/components/StudentAffairsTab.tsx
Normal file
117
management/src/components/StudentAffairsTab.tsx
Normal 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 lý các hoạt động hỗ trợ, tư vấn và 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ợ tư vấn, giải quyết các khó khăn, theo sát quá trình học tập và đờ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 và 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>
|
||||
);
|
||||
};
|
||||
@@ -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 có 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>
|
||||
|
||||
Reference in New Issue
Block a user