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:
1
management/src/.env
Normal file
1
management/src/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=https://sv.rikkeiraia.org/api
|
||||||
@@ -7,6 +7,7 @@ import { ExamsTab } from './components/ExamsTab';
|
|||||||
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
||||||
import { SystemTab } from './components/SystemTab';
|
import { SystemTab } from './components/SystemTab';
|
||||||
import { MyAccountTab } from './components/AccountsTab';
|
import { MyAccountTab } from './components/AccountsTab';
|
||||||
|
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
||||||
import { ChatWidget } from './components/ChatWidget';
|
import { ChatWidget } from './components/ChatWidget';
|
||||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||||
@@ -68,6 +69,12 @@ const IconSystem = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const IconMenu = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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" />
|
<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>
|
</button>
|
||||||
</li>
|
</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>
|
<div className="nav-header">Hệ thống</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
@@ -267,6 +285,7 @@ function App() {
|
|||||||
{route.tab === 'exams' && <ExamsTab />}
|
{route.tab === 'exams' && <ExamsTab />}
|
||||||
{route.tab === 'system' && <SystemTab />}
|
{route.tab === 'system' && <SystemTab />}
|
||||||
{route.tab === 'profile' && <MyAccountTab />}
|
{route.tab === 'profile' && <MyAccountTab />}
|
||||||
|
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
export function getWsUrl(path: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -269,6 +269,14 @@ export interface StatsResponse {
|
|||||||
totalStudents: number;
|
totalStudents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideLinkItem {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -362,6 +370,33 @@ export const api = {
|
|||||||
return res.json();
|
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: {
|
getClasses: async (params: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|||||||
@@ -119,13 +119,19 @@ export const ClassesTab: React.FC = () => {
|
|||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handleStartSync}
|
onClick={handleStartSync}
|
||||||
disabled={syncStatus?.running}
|
disabled={syncStatus?.running}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
>
|
>
|
||||||
{syncStatus?.running ? (
|
{syncStatus?.running ? (
|
||||||
<>
|
<>
|
||||||
<div className="sync-spinner"></div> Đồng bộ...
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,8 +144,24 @@ export const ClassesTab: React.FC = () => {
|
|||||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||||
<span>
|
<span>
|
||||||
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
|
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
|
||||||
{syncStatus.done && '🎉 Đồng bộ lớp học hoàn tất!'}
|
{syncStatus.done && (
|
||||||
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
@@ -171,7 +193,12 @@ export const ClassesTab: React.FC = () => {
|
|||||||
setPage(1);
|
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 className="filters-wrapper">
|
<div className="filters-wrapper">
|
||||||
@@ -217,7 +244,12 @@ export const ClassesTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : classes.length === 0 ? (
|
) : classes.length === 0 ? (
|
||||||
<div className="empty-state">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -249,13 +281,21 @@ export const ClassesTab: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{cl.name}
|
{cl.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem' }}>
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
💼 {cl.specializeName || 'Chưa có chuyên ngành'}
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge badge-muted" style={{ fontWeight: 600 }}>
|
<span className="badge badge-muted" style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
📍 {cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -272,8 +312,12 @@ export const ClassesTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600 }}>
|
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
👤 {cl.studentCount} SV
|
<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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<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' }}
|
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)}
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -327,8 +377,11 @@ export const ClassesTab: React.FC = () => {
|
|||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
disabled={page === 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>
|
</button>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||||
@@ -337,8 +390,11 @@ export const ClassesTab: React.FC = () => {
|
|||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
disabled={page >= Math.ceil(total / pageSize)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,73 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api } from '../api';
|
import { api, type GuideLinkItem } from '../api';
|
||||||
import type { StatsResponse } from '../api';
|
import type { StatsResponse } from '../api';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
interface DashboardTabProps {
|
interface DashboardTabProps {
|
||||||
onNavigate: (tab: string) => void;
|
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 }) => {
|
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 [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -24,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStats();
|
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 (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<div className="tab-page-toolbar">
|
<div className="tab-page-toolbar">
|
||||||
@@ -46,8 +150,10 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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" 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)' }}>⚠️</div>
|
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}>
|
||||||
|
<IconAlert />
|
||||||
|
</div>
|
||||||
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
|
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -60,7 +166,9 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
<div className="stat-label">Tổng lớp học đã đồng bộ</div>
|
<div className="stat-label">Tổng lớp học đã đồng bộ</div>
|
||||||
<div className="stat-value">{stats.totalClasses}</div>
|
<div className="stat-value">{stats.totalClasses}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-icon">🏫</div>
|
<div className="stat-icon" style={{ color: 'var(--accent)' }}>
|
||||||
|
<IconClasses />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
||||||
@@ -68,7 +176,9 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
<div className="stat-label">Lớp đang giảng dạy</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 className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-icon success">✓</div>
|
<div className="stat-icon success" style={{ color: 'var(--success)', backgroundColor: 'var(--success-light)' }}>
|
||||||
|
<IconActiveClasses />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
|
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
|
||||||
@@ -76,48 +186,205 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
<div className="stat-label">Tổng sinh viên đã đồng bộ</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 className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-icon">🎓</div>
|
<div className="stat-icon" style={{ color: 'var(--accent-hover)' }}>
|
||||||
|
<IconStudents />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content-grid-2">
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
<div className="content-card">
|
<div className="content-card">
|
||||||
<h2>Khởi động nhanh</h2>
|
<h2>Khởi động nhanh</h2>
|
||||||
<p>
|
<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>.
|
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ộ.
|
Hệ thống hỗ trợ quản lý lớp học, thi cử và chăm sóc sinh viên.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||||
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
|
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
|
||||||
Quản lý lớp học
|
Quản lý lớp học
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
|
<button className="btn btn-secondary" onClick={() => onNavigate('learning')}>
|
||||||
Danh sách sinh viên
|
Lịch học & Giám sát
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => onNavigate('exams')}>
|
||||||
|
Quản lý phòng thi
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { apiExam, type ExamRoomItem } from '../api';
|
import { apiExam, type ExamRoomItem } from '../api';
|
||||||
import { openExam } from './NavHistoryBar';
|
import { openExam } from './NavHistoryBar';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
try {
|
try {
|
||||||
@@ -32,6 +33,9 @@ function localInputToISO(v: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ExamsTab: React.FC = () => {
|
export const ExamsTab: React.FC = () => {
|
||||||
|
const { staff } = useAuth();
|
||||||
|
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
|
||||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
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 (
|
return (
|
||||||
<div className="tab-page exams-page">
|
<div className="tab-page exams-page">
|
||||||
<header className="page-header page-header--row">
|
<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>
|
<span>{r.studentCount} sinh viên · {r.paperCount} đề</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}>
|
<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
|
Mở phòng thi
|
||||||
</button>
|
</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>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -266,32 +266,41 @@ export const LearningTab: React.FC = () => {
|
|||||||
|
|
||||||
<div className="content-card">
|
<div className="content-card">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="learning-empty">
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
<span className="learning-empty-icon">🔄</span>
|
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||||
<p>Đang tải...</p>
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải dữ liệu...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && activeClasses.length === 0 && (
|
{!loading && activeClasses.length === 0 && (
|
||||||
<div className="learning-empty">
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
<span className="learning-empty-icon">📅</span>
|
<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' }}>
|
||||||
<p>Chưa có lớp nào được tạo lịch học.</p>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
<button className="btn btn-secondary" onClick={handleOpenAddClass}>
|
<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
|
Bắt đầu thêm lớp đầu tiên
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||||||
<div className="learning-empty">
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
<span className="learning-empty-icon">🔍</span>
|
<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' }}>
|
||||||
<p>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
<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
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setDayFilter(null);
|
setDayFilter(null);
|
||||||
}}
|
}}
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
>
|
>
|
||||||
Xóa bộ lọc
|
Xóa bộ lọc
|
||||||
</button>
|
</button>
|
||||||
@@ -385,8 +394,8 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configClass && (
|
{configClass && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay" onClick={() => setConfigClass(null)}>
|
||||||
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }}>
|
<div className="modal-container" style={{ maxWidth: '800px', width: '90%' }} onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="modal-title">Cấu hình lịch học & ứng dụng</h2>
|
<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})
|
{configClass.name} ({configClass.classCode})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
<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' }}>
|
<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>
|
<h4 style={{ margin: '0 0 1rem 0' }}>Ca học trong tuần</h4>
|
||||||
<ScheduleEditor
|
<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.
|
Từ khóa ngăn chặn các ứng dụng ngoài luồng trên máy sinh viên.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
rows={6}
|
rows={8}
|
||||||
value={allowedApps}
|
value={allowedApps}
|
||||||
onChange={e => setAllowedApps(e.target.value)}
|
onChange={e => setAllowedApps(e.target.value)}
|
||||||
placeholder="chrome,idea64,vscode,wails,simple_care"
|
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>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
<button className="btn btn-primary" onClick={handleSaveApps} style={{ width: '100%', marginTop: '15px' }}>
|
||||||
@@ -424,6 +447,9 @@ export const LearningTab: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setConfigClass(null)}>Đóng</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -500,33 +526,64 @@ export const LearningTab: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showAddClassModal && (
|
{showAddClassModal && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay" onClick={() => setShowAddClassModal(false)}>
|
||||||
<div className="modal-container" style={{ maxWidth: '500px' }}>
|
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2 className="modal-title">Chọn lớp học từ hệ thống</h2>
|
<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>
|
||||||
<div style={{ padding: '1rem 0' }}>
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
|
<div className="search-input-wrapper" style={{ width: '100%', marginBottom: '1.25rem' }}>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="app-pool-search"
|
className="search-input"
|
||||||
placeholder="Tìm tên lớp hoặc mã lớp..."
|
placeholder="Tìm tên lớp hoặc mã lớp..."
|
||||||
value={searchClassQuery}
|
value={searchClassQuery}
|
||||||
onChange={e => setSearchClassQuery(e.target.value)}
|
onChange={e => setSearchClassQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div style={{ maxHeight: '300px', overflowY: 'auto', marginTop: '1rem' }}>
|
<span className="search-icon">
|
||||||
{filteredAddClasses.length === 0 ? (
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>Không tìm thấy lớp phù hợp.</p>
|
<circle cx="11" cy="11" r="8" />
|
||||||
) : (
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
<ul className="learning-add-list">
|
</svg>
|
||||||
{filteredAddClasses.map(c => (
|
</span>
|
||||||
<li key={c.rkId} className="learning-add-item">
|
|
||||||
<div>
|
|
||||||
<div className="learning-class-code">{c.classCode}</div>
|
|
||||||
<div className="learning-class-meta">{c.name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => handleActivateClass(c)}>
|
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||||
Chọn
|
{filteredAddClasses.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1rem 0' }}>Không tìm thấy lớp phù hợp.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{filteredAddClasses.map(c => (
|
||||||
|
<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 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)} 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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -534,6 +591,9 @@ export const LearningTab: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowAddClassModal(false)}>Đóng</button>
|
||||||
|
</div>
|
||||||
</div>
|
</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"
|
className="btn btn-primary"
|
||||||
onClick={handleStartSync}
|
onClick={handleStartSync}
|
||||||
disabled={syncStatus?.running}
|
disabled={syncStatus?.running}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
>
|
>
|
||||||
{syncStatus?.running ? (
|
{syncStatus?.running ? (
|
||||||
<>
|
<>
|
||||||
<div className="sync-spinner"></div> Đồng bộ...
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,8 +124,24 @@ export const StudentsTab: React.FC = () => {
|
|||||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||||
<span>
|
<span>
|
||||||
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
|
{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.done && (
|
||||||
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
@@ -151,7 +173,12 @@ export const StudentsTab: React.FC = () => {
|
|||||||
setPage(1);
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +193,14 @@ export const StudentsTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : students.length === 0 ? (
|
) : students.length === 0 ? (
|
||||||
<div className="empty-state">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -192,12 +226,33 @@ export const StudentsTab: React.FC = () => {
|
|||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div>📧 {st.email}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
{st.phone && <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>📞 {st.phone}</div>}
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
<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'}
|
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -232,8 +287,11 @@ export const StudentsTab: React.FC = () => {
|
|||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
disabled={page === 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>
|
</button>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||||
@@ -242,8 +300,11 @@ export const StudentsTab: React.FC = () => {
|
|||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
disabled={page >= Math.ceil(total / pageSize)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type SystemSection = 'organization' | 'network' | 'templates';
|
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 {
|
export interface NavEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +27,7 @@ export const TAB_LABELS: Record<TabId, string> = {
|
|||||||
exams: 'Phòng thi',
|
exams: 'Phòng thi',
|
||||||
system: 'Hệ thống',
|
system: 'Hệ thống',
|
||||||
profile: 'Tài khoản của tôi',
|
profile: 'Tài khoản của tôi',
|
||||||
|
'student-affairs': 'Công Tác Sinh Viên',
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_KEY = 'sc_nav_history';
|
const HISTORY_KEY = 'sc_nav_history';
|
||||||
@@ -40,7 +41,8 @@ function isTabId(value: string | null): value is TabId {
|
|||||||
value === 'learning' ||
|
value === 'learning' ||
|
||||||
value === 'exams' ||
|
value === 'exams' ||
|
||||||
value === 'system' ||
|
value === 'system' ||
|
||||||
value === 'profile'
|
value === 'profile' ||
|
||||||
|
value === 'student-affairs'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.StaffGitHubAuth{},
|
&models.StaffGitHubAuth{},
|
||||||
&models.StaffGitHubRepo{},
|
&models.StaffGitHubRepo{},
|
||||||
&models.SystemSetting{},
|
&models.SystemSetting{},
|
||||||
|
&models.GuideLink{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,11 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler {
|
|||||||
// DELETE /api/exam-rooms/:id
|
// DELETE /api/exam-rooms/:id
|
||||||
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
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")
|
id, err := parseUintParam(c, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
||||||
|
|||||||
124
server/internal/handlers/handlers_guide_links.go
Normal file
124
server/internal/handlers/handlers_guide_links.go
Normal 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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,3 +188,15 @@ type SystemSetting struct {
|
|||||||
|
|
||||||
func (SystemSetting) TableName() string { return "system_settings" }
|
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" }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,11 @@ func main() {
|
|||||||
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
|
staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB))
|
||||||
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(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() {
|
go func() {
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user