t1
This commit is contained in:
@@ -1,184 +1 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
/* App.css cleared for index.css overrides */
|
||||
|
||||
@@ -1,122 +1,68 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { useState } from 'react';
|
||||
import { DashboardTab } from './components/DashboardTab';
|
||||
import { ClassesTab } from './components/ClassesTab';
|
||||
import { StudentsTab } from './components/StudentsTab';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState<string>('dashboard');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
{/* Sidebar navigation panel */}
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-logo">SC</div>
|
||||
<span className="brand-name">Simple Care</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
<nav>
|
||||
<ul className="nav-links">
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'dashboard' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
>
|
||||
<span className="nav-icon">📊</span>
|
||||
Tổng Quan
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'classes' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('classes')}
|
||||
>
|
||||
<span className="nav-icon">🏫</span>
|
||||
Lớp Học
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
className={`nav-btn ${activeTab === 'students' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('students')}
|
||||
>
|
||||
<span className="nav-icon">🎓</span>
|
||||
Sinh Viên
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
<div className="sidebar-footer">
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cấu hình Token:</div>
|
||||
<div className="token-badge" title="Token QLDT_TOKEN load từ server .env">
|
||||
🔑 QLDT_TOKEN (Loaded)
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main dashboard content container */}
|
||||
<main className="main-content">
|
||||
{activeTab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
|
||||
{activeTab === 'classes' && <ClassesTab />}
|
||||
{activeTab === 'students' && <StudentsTab />}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
145
management/src/api.ts
Normal file
145
management/src/api.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
const API_BASE = 'http://127.0.0.1:8080/api';
|
||||
|
||||
export interface ClassItem {
|
||||
rkId: number;
|
||||
name: string;
|
||||
classCode: string;
|
||||
type: string;
|
||||
studentCount: number;
|
||||
specializeRkId?: number;
|
||||
specializeName?: string;
|
||||
systemRkId?: number;
|
||||
systemCode?: string;
|
||||
systemName?: string;
|
||||
isStudying: boolean;
|
||||
courses: { courseRkId: number; courseName: string }[];
|
||||
}
|
||||
|
||||
export interface StudentItem {
|
||||
id: number;
|
||||
rkId: number;
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
email: string;
|
||||
dateOfBirth?: string;
|
||||
gender?: number;
|
||||
status?: string;
|
||||
location?: string;
|
||||
systemId?: number;
|
||||
systemName?: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
totalClasses: number;
|
||||
activeClasses: number;
|
||||
totalStudents: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
running: boolean;
|
||||
done: boolean;
|
||||
error?: string;
|
||||
total: number;
|
||||
synced: number;
|
||||
updatedAt: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStats: async (): Promise<StatsResponse> => {
|
||||
const res = await fetch(`${API_BASE}/stats`);
|
||||
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getClasses: async (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q?: string;
|
||||
systemRkId?: number;
|
||||
studyingOnly?: boolean;
|
||||
}): Promise<PaginatedResponse<ClassItem>> => {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
});
|
||||
if (params.q) query.set('q', params.q);
|
||||
if (params.systemRkId) query.set('systemRkId', String(params.systemRkId));
|
||||
if (params.studyingOnly) query.set('studyingOnly', 'true');
|
||||
|
||||
const res = await fetch(`${API_BASE}/classes?${query}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch classes');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateClassStudying: async (rkId: number, isStudying: boolean): Promise<any> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/studying`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isStudying }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update class studying status');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => {
|
||||
const res = await fetch(`${API_BASE}/classes/${rkId}/students`);
|
||||
if (!res.ok) throw new Error('Failed to fetch class students roster');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getStudents: async (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
q?: string;
|
||||
}): Promise<PaginatedResponse<StudentItem>> => {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
});
|
||||
if (params.q) query.set('q', params.q);
|
||||
|
||||
const res = await fetch(`${API_BASE}/students?${query}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch students');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
startClassesSync: async (): Promise<{ ok: boolean; message: string }> => {
|
||||
const res = await fetch(`${API_BASE}/sync/classes/start`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to start classes sync');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getClassesSyncStatus: async (): Promise<SyncStatus> => {
|
||||
const res = await fetch(`${API_BASE}/sync/classes/status`);
|
||||
if (!res.ok) throw new Error('Failed to get classes sync status');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => {
|
||||
const res = await fetch(`${API_BASE}/sync/students/start`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to start students sync');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getStudentsSyncStatus: async (): Promise<SyncStatus> => {
|
||||
const res = await fetch(`${API_BASE}/sync/students/status`);
|
||||
if (!res.ok) throw new Error('Failed to get students sync status');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
430
management/src/components/ClassesTab.tsx
Normal file
430
management/src/components/ClassesTab.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { ClassItem, StudentItem, SyncStatus } from '../api';
|
||||
|
||||
export const ClassesTab: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
|
||||
const [studyingOnly, setStudyingOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
// Modal detail class
|
||||
const [activeClass, setActiveClass] = useState<ClassItem | null>(null);
|
||||
const [modalStudents, setModalStudents] = useState<StudentItem[]>([]);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.getClasses({
|
||||
page,
|
||||
pageSize,
|
||||
q: search || undefined,
|
||||
systemRkId: systemFilter,
|
||||
studyingOnly,
|
||||
});
|
||||
setClasses(res.data);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const status = await api.getClassesSyncStatus();
|
||||
setSyncStatus(status);
|
||||
return status.running;
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi lấy status sync:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, [page, search, systemFilter, studyingOnly]);
|
||||
|
||||
// Check sync status on load and start polling if running
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
timer = setInterval(async () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchClasses(); // Reload data when sync completes
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [syncStatus?.running]);
|
||||
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startClassesSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ lớp học');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStudying = async (cItem: ClassItem) => {
|
||||
const nextVal = !cItem.isStudying;
|
||||
try {
|
||||
// Optimistic update
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
|
||||
await api.updateClassStudying(cItem.rkId, nextVal);
|
||||
} catch (err) {
|
||||
// Revert if error
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
|
||||
alert('Không thể cập nhật trạng thái lớp học');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenStudentsModal = async (cItem: ClassItem) => {
|
||||
setActiveClass(cItem);
|
||||
setModalStudents([]);
|
||||
try {
|
||||
setModalLoading(true);
|
||||
const res = await api.getClassStudents(cItem.rkId);
|
||||
setModalStudents(res.data);
|
||||
} catch (err) {
|
||||
alert('Không thể tải danh sách học sinh của lớp');
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Quản Lý Lớp Học</h1>
|
||||
<p>Đồng bộ dữ liệu lớp học và sinh viên từ hệ thống chính</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
</>
|
||||
) : (
|
||||
'🔄 Đồng bộ Lớp học'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner">
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{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}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Đã đồng bộ: {syncStatus.synced} / {syncStatus.total} lớp
|
||||
</div>
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-meta">
|
||||
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
|
||||
{syncStatus.running && <span style={{ color: 'var(--accent-hover)' }}>Vui lòng không đóng trình duyệt</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control bar filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm theo tên, mã lớp, chuyên ngành..."
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div className="filters-wrapper">
|
||||
<select
|
||||
className="select-filter"
|
||||
value={systemFilter === undefined ? '' : systemFilter}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setSystemFilter(val === '' ? undefined : Number(val));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">Tất cả phân hệ</option>
|
||||
<option value="1">Hà Nội (System 1)</option>
|
||||
<option value="2">Hồ Chí Minh (System 2)</option>
|
||||
<option value="3">Đà Nẵng (System 3)</option>
|
||||
<option value="4">Cần Thơ (System 4)</option>
|
||||
</select>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={studyingOnly}
|
||||
onChange={e => {
|
||||
setStudyingOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: '16px', height: '16px', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
Chỉ xem lớp đang học
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classes list table */}
|
||||
<div className="table-wrapper">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách lớp học...</p>
|
||||
</div>
|
||||
) : classes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏫</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>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã lớp / ID</th>
|
||||
<th>Tên Lớp học</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Môn học của lớp</th>
|
||||
<th>Sinh viên</th>
|
||||
<th>Đang dạy</th>
|
||||
<th style={{ textAlign: 'right' }}>Hành động</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(cl => (
|
||||
<tr key={cl.rkId}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{cl.classCode}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500, color: 'white' }}>{cl.name}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||
{cl.specializeName || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted">
|
||||
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="courses-tag-list">
|
||||
{cl.courses && cl.courses.length > 0 ? (
|
||||
cl.courses.map((co, i) => (
|
||||
<span key={i} className="course-tag">
|
||||
{co.courseName}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa có môn học</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-info">
|
||||
👤 {cl.studentCount} sinh viên
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="switch-container">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cl.isStudying}
|
||||
onChange={() => handleToggleStudying(cl)}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem' }}
|
||||
onClick={() => handleOpenStudentsModal(cl)}
|
||||
>
|
||||
Chi tiết 👥
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination control bar */}
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị lớp thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp học
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
|
||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Students roster modal */}
|
||||
{activeClass && (
|
||||
<div className="modal-overlay" onClick={() => setActiveClass(null)}>
|
||||
<div className="modal-container" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<h3>Danh sách sinh viên lớp: {activeClass.name}</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.2rem' }}>
|
||||
Mã lớp: {activeClass.classCode} | Sĩ số: {activeClass.studentCount}
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<div className="detail-item-label">Chuyên Ngành</div>
|
||||
<div className="detail-item-value">{activeClass.specializeName || 'Chưa phân phối'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="detail-item-label">Chi Nhánh / Phân Hệ</div>
|
||||
<div className="detail-item-value">{activeClass.systemName || 'Chung'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="detail-item-label">Loại Lớp</div>
|
||||
<div className="detail-item-value" style={{ textTransform: 'capitalize' }}>{activeClass.type || 'Chính thức'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalLoading ? (
|
||||
<div className="empty-state" style={{ minHeight: '200px' }}>
|
||||
<div className="sync-spinner"></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách học sinh...</p>
|
||||
</div>
|
||||
) : modalStudents.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '200px' }}>
|
||||
<div className="empty-state-icon">👥</div>
|
||||
<h3>Lớp trống hoặc chưa đồng bộ danh sách</h3>
|
||||
<p style={{ fontSize: '0.85rem', maxWidth: '380px', margin: '0 auto' }}>
|
||||
Hãy kiểm tra xem lớp có gán môn học nào hoạt động không. Danh sách sinh viên được kéo từ kết quả học tập của lớp.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Email</th>
|
||||
<th>Số Điện Thoại</th>
|
||||
<th>Vị Trí</th>
|
||||
<th>Trạng Thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modalStudents.map(st => (
|
||||
<tr key={st.rkId}>
|
||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
||||
<td style={{ color: 'white', fontWeight: 500 }}>{st.fullName}</td>
|
||||
<td>{st.email}</td>
|
||||
<td>{st.phone || '—'}</td>
|
||||
<td>{st.location || '—'}</td>
|
||||
<td>
|
||||
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
|
||||
{st.status || 'Đang học'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setActiveClass(null)}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
management/src/components/DashboardTab.tsx
Normal file
137
management/src/components/DashboardTab.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StatsResponse } from '../api';
|
||||
|
||||
interface DashboardTabProps {
|
||||
onNavigate: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getStats();
|
||||
setStats(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Không thể tải dữ liệu thống kê');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Tổng Quan Hệ Thống</h1>
|
||||
<p>Giám sát đồng bộ và quản lý lớp học, sinh viên từ hệ thống chính</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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" style={{ color: 'var(--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>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
marginTop: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '2rem',
|
||||
borderRadius: '18px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1rem', color: 'white', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
🚀 Khởi động nhanh
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: '1.6', marginBottom: '1.5rem' }}>
|
||||
Chào mừng bạn đến với trang quản trị <b>Simple Care</b>.
|
||||
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ộ mà không lo ảnh hưởng đến dữ liệu hiện có.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
padding: '2rem',
|
||||
borderRadius: '18px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1rem', color: 'white' }}>📋 Quy trình hoạt động</h2>
|
||||
<ul style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: '1.8',
|
||||
paddingLeft: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<li><b>Đồng bộ Lớp học:</b> Tải toàn bộ danh sách lớp học và các môn học tương ứng. Đồng thời kéo thông tin sinh viên thuộc từng lớp thông qua bảng kết quả lớp học.</li>
|
||||
<li><b>Quản lý Lớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc mã lớp và Bật/Tắt cờ <b>Đang học</b>.</li>
|
||||
<li><b>Đồng bộ Sinh viên:</b> Thực hiện kéo toàn bộ sinh viên trên hệ thống chính (kèm thông tin liên hệ, hệ học) lưu trữ vào Database phục vụ cho học tập và thi cử.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
management/src/components/StudentsTab.tsx
Normal file
248
management/src/components/StudentsTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StudentItem, SyncStatus } from '../api';
|
||||
|
||||
export const StudentsTab: React.FC = () => {
|
||||
const [students, setStudents] = useState<StudentItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
const fetchStudents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await api.getStudents({
|
||||
page,
|
||||
pageSize,
|
||||
q: search || undefined,
|
||||
});
|
||||
setStudents(res.data);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const status = await api.getStudentsSyncStatus();
|
||||
setSyncStatus(status);
|
||||
return status.running;
|
||||
} catch (err) {
|
||||
console.error('Lỗi khi lấy status sync:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStudents();
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
timer = setInterval(async () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchStudents(); // Reload data when sync completes
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [syncStatus?.running]);
|
||||
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startStudentsSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên');
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Danh Sách Sinh Viên</h1>
|
||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
</>
|
||||
) : (
|
||||
'🔄 Đồng bộ toàn bộ SV'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner" style={{ borderStyle: 'solid', borderColor: 'var(--success)' }}>
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{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}`}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
|
||||
</div>
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%`, background: 'linear-gradient(to right, var(--success), #a7f3d0)' }}></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-meta">
|
||||
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
|
||||
{syncStatus.running && <span style={{ color: 'var(--success)' }}>Hệ thống đang kéo dữ liệu trang {syncStatus.page}...</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Students Table */}
|
||||
<div className="table-wrapper">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách sinh viên...</p>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">👥</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>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Thông Tin Liên Hệ</th>
|
||||
<th>Ngày sinh / Giới tính</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Địa điểm</th>
|
||||
<th>Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map(st => (
|
||||
<tr key={st.id}>
|
||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
||||
<td style={{ color: 'white', fontWeight: 500 }}>
|
||||
{st.fullName}
|
||||
<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>}
|
||||
</td>
|
||||
<td>
|
||||
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted">
|
||||
{st.systemName || 'Chung'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{st.location || '—'}</td>
|
||||
<td>
|
||||
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
|
||||
{st.status || 'Đang học'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loading && students.length > 0 && (
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'white' }}>
|
||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,111 +1,741 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--bg-app: #0d0e12;
|
||||
--bg-card: rgba(22, 24, 33, 0.7);
|
||||
--bg-sidebar: #13151f;
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent: #8b5cf6;
|
||||
--accent-glow: rgba(139, 92, 246, 0.15);
|
||||
--accent-hover: #a78bfa;
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--success: #10b981;
|
||||
--success-glow: rgba(16, 185, 129, 0.15);
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--glass: backdrop-filter: blur(16px) saturate(180%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-app);
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Sidebar Styling */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 1.5rem;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: linear-gradient(135deg, var(--accent), #d8b4fe);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(to right, #ffffff, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-primary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(90deg, var(--accent), rgba(139, 92, 246, 0.4));
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-badge {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.main-content {
|
||||
margin-left: 260px;
|
||||
flex: 1;
|
||||
padding: 2.5rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Header Area */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: var(--transition-smooth);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Control Panel Bar */
|
||||
.control-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(139, 92, 246, 0.25);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filters-wrapper {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-filter {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-filter:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #7c3aed);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #9333ea, #6d28d9);
|
||||
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sync Banner Box */
|
||||
.sync-progress-banner {
|
||||
background: linear-gradient(135deg, rgba(22, 24, 33, 0.9), rgba(139, 92, 246, 0.05));
|
||||
border: 1px dashed var(--accent);
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sync-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sync-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sync-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.sync-bar-container {
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, var(--accent), #d8b4fe);
|
||||
box-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sync-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Table Area */
|
||||
.table-wrapper {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1.15rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1.15rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.switch-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transition: .3s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Badge Styles */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--success-glow);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--accent-glow);
|
||||
color: var(--accent-hover);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Course tag list */
|
||||
.courses-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Pagination Row */
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination-btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background-color: var(--accent-glow);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty State styling */
|
||||
.empty-state {
|
||||
padding: 4rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal dialog glassmorphism */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background-color: #11131c;
|
||||
border: 1px solid var(--border-color);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 85vh;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
animation: modalScaleUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes modalScaleUp {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Detail Lists inside modal */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: rgba(255, 255, 255, 0.01);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.detail-item-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-item-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
26
server/go.mod
Normal file
26
server/go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module server
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/gofiber/fiber/v2 v2.52.13 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.2 // indirect
|
||||
)
|
||||
43
server/go.sum
Normal file
43
server/go.sum
Normal file
@@ -0,0 +1,43 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
|
||||
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
44
server/internal/db/db.go
Normal file
44
server/internal/db/db.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"server/internal/models"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ConnectDB(host, port, user, password, dbName string) (*gorm.DB, error) {
|
||||
// Kết nối ban đầu không chọn database để tạo database nếu chưa tồn tại
|
||||
dsnWithoutDB := fmt.Sprintf("%s:%s@tcp(%s:%s)/?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port)
|
||||
tmpDB, err := gorm.Open(mysql.Open(dsnWithoutDB), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to MySQL server: %w", err)
|
||||
}
|
||||
|
||||
createDBQuery := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;", dbName)
|
||||
if err := tmpDB.Exec(createDBQuery).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create database: %w", err)
|
||||
}
|
||||
|
||||
// Kết nối chính thức vào database chỉ định
|
||||
dsnWithDB := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, password, host, port, dbName)
|
||||
gormDB, err := gorm.Open(mysql.Open(dsnWithDB), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database %s: %w", dbName, err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully connected to MySQL database: %s", dbName)
|
||||
return gormDB, nil
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
log.Println("Running AutoMigrate...")
|
||||
return db.AutoMigrate(
|
||||
&models.Class{},
|
||||
&models.ClassCourseLink{},
|
||||
&models.Student{},
|
||||
&models.ClassStudent{},
|
||||
)
|
||||
}
|
||||
298
server/internal/handlers/handlers.go
Normal file
298
server/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/internal/models"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Helper convert string to int
|
||||
func atoiDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Handler bắt đầu đồng bộ lớp học
|
||||
func StartClassesSyncHandler(db *gorm.DB, client *qldt.Client, job *syncjobs.ClassesSyncJob, token string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token QLDT_TOKEN"})
|
||||
}
|
||||
started := job.Start(db, client, token)
|
||||
if !started {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Sync classes is already running"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Classes sync job started"})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler lấy status đồng bộ lớp học
|
||||
func GetClassesSyncStatusHandler(job *syncjobs.ClassesSyncJob) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
return c.JSON(job.Status())
|
||||
}
|
||||
}
|
||||
|
||||
// Handler bắt đầu đồng bộ toàn bộ sinh viên
|
||||
func StartStudentsSyncHandler(db *gorm.DB, client *qldt.Client, job *syncjobs.StudentsSyncJob, token string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing token QLDT_TOKEN"})
|
||||
}
|
||||
started := job.Start(db, client, token)
|
||||
if !started {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Sync students is already running"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Students sync job started"})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler lấy status đồng bộ sinh viên
|
||||
func GetStudentsSyncStatusHandler(job *syncjobs.StudentsSyncJob) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
return c.JSON(job.Status())
|
||||
}
|
||||
}
|
||||
|
||||
type courseRefItem struct {
|
||||
CourseRkID int64 `json:"courseRkId"`
|
||||
CourseName string `json:"courseName"`
|
||||
}
|
||||
|
||||
type classItem struct {
|
||||
RkID int64 `json:"rkId"`
|
||||
Name string `json:"name"`
|
||||
ClassCode string `json:"classCode"`
|
||||
Type string `json:"type"`
|
||||
StudentCount int `json:"studentCount"`
|
||||
SpecializeRkID *int64 `json:"specializeRkId"`
|
||||
SpecializeName *string `json:"specializeName"`
|
||||
SystemRkID *int64 `json:"systemRkId"`
|
||||
SystemCode *string `json:"systemCode"`
|
||||
SystemName *string `json:"systemName"`
|
||||
IsStudying bool `json:"isStudying"`
|
||||
Courses []courseRefItem `json:"courses"`
|
||||
}
|
||||
|
||||
// Handler danh sách lớp học
|
||||
func ListClassesHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page := atoiDefault(c.Query("page"), 1)
|
||||
pageSize := atoiDefault(c.Query("pageSize"), 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
queryDB := db.Model(&models.Class{})
|
||||
|
||||
if sys := c.Query("systemRkId"); sys != "" {
|
||||
if id, err := strconv.ParseInt(sys, 10, 64); err == nil {
|
||||
queryDB = queryDB.Where("system_rk_id = ?", id)
|
||||
}
|
||||
}
|
||||
|
||||
if s := strings.TrimSpace(c.Query("q")); s != "" {
|
||||
pat := "%" + s + "%"
|
||||
queryDB = queryDB.Where("(name LIKE ? OR class_code LIKE ? OR type LIKE ? OR specialize_name LIKE ? OR system_name LIKE ?)", pat, pat, pat, pat, pat)
|
||||
}
|
||||
|
||||
studyingOnly := c.Query("studyingOnly")
|
||||
if studyingOnly == "1" || studyingOnly == "true" {
|
||||
queryDB = queryDB.Where("is_studying = ?", true)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := queryDB.Count(&total).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count classes: " + err.Error()})
|
||||
}
|
||||
|
||||
var rows []models.Class
|
||||
if err := queryDB.Order("rk_id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to query classes: " + err.Error()})
|
||||
}
|
||||
|
||||
// Kéo danh sách Courses cho các lớp
|
||||
var classIDs []int64
|
||||
for _, r := range rows {
|
||||
classIDs = append(classIDs, r.RkID)
|
||||
}
|
||||
|
||||
coursesByClass := map[int64][]courseRefItem{}
|
||||
if len(classIDs) > 0 {
|
||||
var links []models.ClassCourseLink
|
||||
if err := db.Where("class_rk_id IN ?", classIDs).Order("course_order asc").Find(&links).Error; err == nil {
|
||||
for _, link := range links {
|
||||
coursesByClass[link.ClassRkID] = append(coursesByClass[link.ClassRkID], courseRefItem{
|
||||
CourseRkID: link.CourseRkID,
|
||||
CourseName: link.CourseName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]classItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
courses := coursesByClass[r.RkID]
|
||||
if courses == nil {
|
||||
courses = []courseRefItem{}
|
||||
}
|
||||
out = append(out, classItem{
|
||||
RkID: r.RkID,
|
||||
Name: r.Name,
|
||||
ClassCode: r.ClassCode,
|
||||
Type: r.Type,
|
||||
StudentCount: r.StudentCount,
|
||||
SpecializeRkID: r.SpecializeRkID,
|
||||
SpecializeName: r.SpecializeName,
|
||||
SystemRkID: r.SystemRkID,
|
||||
SystemCode: r.SystemCode,
|
||||
SystemName: r.SystemName,
|
||||
IsStudying: r.IsStudying,
|
||||
Courses: courses,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"data": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler cập nhật trạng thái đang học (isStudying) của lớp học
|
||||
func PatchClassStudyingHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkIDStr := c.Params("rkId")
|
||||
rkID, err := strconv.ParseInt(rkIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
var body struct {
|
||||
IsStudying bool `json:"isStudying"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body payload"})
|
||||
}
|
||||
|
||||
var cl models.Class
|
||||
if err := db.Where("rk_id = ?", rkID).First(&cl).Error; err != nil {
|
||||
if gorm.ErrRecordNotFound == err {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Class not found"})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
cl.IsStudying = body.IsStudying
|
||||
if err := db.Save(&cl).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update class: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "data": cl})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler lấy danh sách sinh viên của lớp học cụ thể
|
||||
func ListClassStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
rkIDStr := c.Params("rkId")
|
||||
classRkID, err := strconv.ParseInt(rkIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"})
|
||||
}
|
||||
|
||||
// Tìm danh sách student_rk_id thuộc lớp
|
||||
var links []models.ClassStudent
|
||||
if err := db.Where("class_rk_id = ?", classRkID).Find(&links).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to query class students: " + err.Error()})
|
||||
}
|
||||
|
||||
var studentRkIDs []int64
|
||||
for _, link := range links {
|
||||
studentRkIDs = append(studentRkIDs, link.StudentRkID)
|
||||
}
|
||||
|
||||
students := []models.Student{}
|
||||
if len(studentRkIDs) > 0 {
|
||||
if err := db.Where("rk_id IN ?", studentRkIDs).Order("full_name asc").Find(&students).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load students info: " + err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"data": students, "total": len(students)})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler danh sách toàn bộ sinh viên (đã sync)
|
||||
func ListAllStudentsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page := atoiDefault(c.Query("page"), 1)
|
||||
pageSize := atoiDefault(c.Query("pageSize"), 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
queryDB := db.Model(&models.Student{})
|
||||
|
||||
if s := strings.TrimSpace(c.Query("q")); s != "" {
|
||||
pat := "%" + s + "%"
|
||||
queryDB = queryDB.Where("(full_name LIKE ? OR student_code LIKE ? OR email LIKE ? OR phone LIKE ?)", pat, pat, pat, pat)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := queryDB.Count(&total).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to count students: " + err.Error()})
|
||||
}
|
||||
|
||||
var students []models.Student
|
||||
if err := queryDB.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&students).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load students: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"data": students,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handler lấy stats nhanh hiển thị ở Dashboard
|
||||
func GetStatsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var totalClasses int64
|
||||
var activeClasses int64
|
||||
var totalStudents int64
|
||||
|
||||
db.Model(&models.Class{}).Count(&totalClasses)
|
||||
db.Model(&models.Class{}).Where("is_studying = ?", true).Count(&activeClasses)
|
||||
db.Model(&models.Student{}).Count(&totalStudents)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"totalClasses": totalClasses,
|
||||
"activeClasses": activeClasses,
|
||||
"totalStudents": totalStudents,
|
||||
})
|
||||
}
|
||||
}
|
||||
61
server/internal/models/models.go
Normal file
61
server/internal/models/models.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Class đại diện cho lớp học từ QLĐT
|
||||
type Class struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
RkID int64 `gorm:"column:rk_id;uniqueIndex;not null" json:"rkId"`
|
||||
Name string `gorm:"column:name;size:512" json:"name"`
|
||||
ClassCode string `gorm:"column:class_code;size:128" json:"classCode"`
|
||||
Type string `gorm:"column:type;size:64" json:"type"`
|
||||
StudentCount int `gorm:"column:student_count;default:0" json:"studentCount"`
|
||||
SpecializeRkID *int64 `gorm:"column:specialize_rk_id" json:"specializeRkId"`
|
||||
SpecializeName *string `gorm:"column:specialize_name;size:255" json:"specializeName"`
|
||||
SystemRkID *int64 `gorm:"column:system_rk_id;index" json:"systemRkId"`
|
||||
SystemCode *string `gorm:"column:system_code;size:64" json:"systemCode"`
|
||||
SystemName *string `gorm:"column:system_name;size:512" json:"systemName"`
|
||||
IsStudying bool `gorm:"column:is_studying;default:false;index" json:"isStudying"`
|
||||
}
|
||||
|
||||
func (Class) TableName() string { return "classes" }
|
||||
|
||||
// ClassCourseLink lưu danh sách môn học của mỗi lớp
|
||||
type ClassCourseLink struct {
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||
CourseRkID int64 `gorm:"column:course_rk_id;primaryKey;not null" json:"courseRkId"`
|
||||
CourseName string `gorm:"column:course_name;size:512" json:"courseName"`
|
||||
CourseOrder int `gorm:"column:course_order;default:0" json:"courseOrder"`
|
||||
}
|
||||
|
||||
func (ClassCourseLink) TableName() string { return "class_course_links" }
|
||||
|
||||
// Student đại diện cho sinh viên
|
||||
type Student struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
RkID int64 `gorm:"column:rk_id;uniqueIndex;not null" json:"rkId"`
|
||||
StudentCode string `gorm:"column:student_code;size:64;index" json:"studentCode"`
|
||||
FullName string `gorm:"column:full_name;size:255;index" json:"fullName"`
|
||||
Phone *string `gorm:"column:phone;size:32" json:"phone"`
|
||||
Email string `gorm:"column:email;size:255;index" json:"email"`
|
||||
DateOfBirth *time.Time `gorm:"column:date_of_birth" json:"dateOfBirth"`
|
||||
Gender *int `gorm:"column:gender" json:"gender"`
|
||||
Status *string `gorm:"column:status;size:64" json:"status"`
|
||||
Location *string `gorm:"column:location;size:32" json:"location"`
|
||||
SystemID *int64 `gorm:"column:system_id" json:"systemId"`
|
||||
SystemName *string `gorm:"column:system_name;size:255" json:"systemName"`
|
||||
}
|
||||
|
||||
func (Student) TableName() string { return "students" }
|
||||
|
||||
// ClassStudent map sinh viên vào lớp tương ứng
|
||||
type ClassStudent struct {
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null;index" json:"classRkId"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;primaryKey;not null;index" json:"studentRkId"`
|
||||
}
|
||||
|
||||
func (ClassStudent) TableName() string { return "class_students" }
|
||||
199
server/internal/qldt/client.go
Normal file
199
server/internal/qldt/client.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package qldt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
origin string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, origin string) *Client {
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
origin: origin,
|
||||
http: &http.Client{
|
||||
Timeout: 45 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type StudentsResponse struct {
|
||||
Message string `json:"message"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
Data []StudentDTO `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page string `json:"page"`
|
||||
PageSize string `json:"pageSize"`
|
||||
}
|
||||
|
||||
type StudentDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
DateOfBirth *string `json:"dateOfBirth"`
|
||||
Gender *int `json:"gender"`
|
||||
Status *string `json:"status"`
|
||||
Location *string `json:"location"`
|
||||
System *struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"system"`
|
||||
}
|
||||
|
||||
type ClassesResponse struct {
|
||||
Data []ClassDTO `json:"data"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
}
|
||||
|
||||
type ClassDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClassCode string `json:"classCode"`
|
||||
Type string `json:"type"`
|
||||
StudentCount int `json:"studentCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
Specializes *struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Systems *struct {
|
||||
ID int64 `json:"id"`
|
||||
SystemCode string `json:"systemCode"`
|
||||
Name string `json:"name"`
|
||||
} `json:"systems"`
|
||||
} `json:"specializes"`
|
||||
Courses []classCourseNestDTO `json:"courses"`
|
||||
}
|
||||
|
||||
type classCourseNestDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (c *Client) GetStudents(ctx context.Context, token string, page, pageSize int) (*StudentsResponse, error) {
|
||||
u, err := url.Parse(c.baseURL + "/students")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("pageSize", strconv.Itoa(pageSize))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if c.origin != "" {
|
||||
req.Header.Set("Origin", c.origin)
|
||||
req.Header.Set("Referer", c.origin+"/")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("qldt students http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var out StudentsResponse
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.StatusCode != 0 && out.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qldt statusCode %d: %s", out.StatusCode, out.Message)
|
||||
}
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetClassesBySystem(ctx context.Context, token string, systemID int64) (*ClassesResponse, error) {
|
||||
u := fmt.Sprintf("%s/classes/system/%d", c.baseURL, systemID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if c.origin != "" {
|
||||
req.Header.Set("Origin", c.origin)
|
||||
req.Header.Set("Referer", c.origin+"/")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("qldt classes/system/%d http %d: %s", systemID, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var outClasses ClassesResponse
|
||||
if err := json.Unmarshal(body, &outClasses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if outClasses.StatusCode != 0 && outClasses.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qldt classes statusCode %d", outClasses.StatusCode)
|
||||
}
|
||||
return &outClasses, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetClassDashboard(ctx context.Context, token string, classID, courseID int64) ([]byte, error) {
|
||||
u := fmt.Sprintf("%s/classes/dashboard/%d/%d", c.baseURL, classID, courseID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if c.origin != "" {
|
||||
req.Header.Set("Origin", c.origin)
|
||||
req.Header.Set("Referer", c.origin+"/")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("qldt classes/dashboard/%d/%d http %d: %s", classID, courseID, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &probe); err == nil {
|
||||
if probe.StatusCode != 0 && probe.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qldt classes/dashboard statusCode %d: %s", probe.StatusCode, probe.Message)
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
556
server/internal/syncjobs/jobs.go
Normal file
556
server/internal/syncjobs/jobs.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package syncjobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/internal/models"
|
||||
"server/internal/qldt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Trạng thái đồng bộ sinh viên toàn hệ thống
|
||||
type StudentsSyncStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Total int `json:"total"`
|
||||
Synced int `json:"synced"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type StudentsSyncJob struct {
|
||||
mu sync.RWMutex
|
||||
status StudentsSyncStatus
|
||||
}
|
||||
|
||||
func NewStudentsSyncJob() *StudentsSyncJob {
|
||||
return &StudentsSyncJob{
|
||||
status: StudentsSyncStatus{UpdatedAt: time.Now().Unix()},
|
||||
}
|
||||
}
|
||||
|
||||
func (j *StudentsSyncJob) Status() StudentsSyncStatus {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.status
|
||||
}
|
||||
|
||||
func (j *StudentsSyncJob) Start(db *gorm.DB, client *qldt.Client, token string) bool {
|
||||
j.mu.Lock()
|
||||
if j.status.Running {
|
||||
j.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
j.status = StudentsSyncStatus{
|
||||
Running: true,
|
||||
Done: false,
|
||||
Page: 0,
|
||||
PageSize: 100,
|
||||
Total: 0,
|
||||
Synced: 0,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
j.mu.Unlock()
|
||||
|
||||
go j.run(db, client, token)
|
||||
return true
|
||||
}
|
||||
|
||||
func (j *StudentsSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
defer func() {
|
||||
j.mu.Lock()
|
||||
j.status.Running = false
|
||||
j.status.Done = j.status.Error == ""
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
j.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
pageSize := 100
|
||||
page := 1
|
||||
total := 0
|
||||
synced := 0
|
||||
|
||||
for {
|
||||
res, err := client.GetStudents(ctx, token, page, pageSize)
|
||||
if err != nil {
|
||||
j.setError(err.Error())
|
||||
return
|
||||
}
|
||||
if total == 0 {
|
||||
total = res.Total
|
||||
}
|
||||
if len(res.Data) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
batch := make([]models.Student, 0, len(res.Data))
|
||||
for _, s := range res.Data {
|
||||
var dob *time.Time
|
||||
if s.DateOfBirth != nil && *s.DateOfBirth != "" {
|
||||
if t, err := time.Parse("2006-01-02", *s.DateOfBirth); err == nil {
|
||||
dob = &t
|
||||
}
|
||||
}
|
||||
|
||||
st := models.Student{
|
||||
RkID: s.ID,
|
||||
StudentCode: s.StudentCode,
|
||||
FullName: s.FullName,
|
||||
Phone: s.Phone,
|
||||
Email: s.Email,
|
||||
DateOfBirth: dob,
|
||||
Gender: s.Gender,
|
||||
Status: s.Status,
|
||||
Location: s.Location,
|
||||
}
|
||||
if s.System != nil {
|
||||
sysID := s.System.ID
|
||||
sysName := s.System.Name
|
||||
st.SystemID = &sysID
|
||||
st.SystemName = &sysName
|
||||
}
|
||||
batch = append(batch, st)
|
||||
}
|
||||
|
||||
if err := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "rk_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"student_code",
|
||||
"full_name",
|
||||
"phone",
|
||||
"email",
|
||||
"date_of_birth",
|
||||
"gender",
|
||||
"status",
|
||||
"location",
|
||||
"system_id",
|
||||
"system_name",
|
||||
"updated_at",
|
||||
}),
|
||||
}).CreateInBatches(batch, 100).Error; err != nil {
|
||||
j.setError("Failed to save students to DB: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
synced += len(batch)
|
||||
|
||||
j.mu.Lock()
|
||||
j.status.Page = page
|
||||
j.status.PageSize = pageSize
|
||||
j.status.Total = total
|
||||
j.status.Synced = synced
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
j.mu.Unlock()
|
||||
|
||||
if synced >= total || len(res.Data) < pageSize {
|
||||
break
|
||||
}
|
||||
page++
|
||||
time.Sleep(50 * time.Millisecond) // Tránh spam API quá tải
|
||||
}
|
||||
}
|
||||
|
||||
func (j *StudentsSyncJob) setError(msg string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
j.status.Error = msg
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Trạng thái đồng bộ lớp học và sinh viên của lớp
|
||||
type ClassesSyncStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Total int `json:"total"`
|
||||
Synced int `json:"synced"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ClassesSyncJob struct {
|
||||
mu sync.RWMutex
|
||||
status ClassesSyncStatus
|
||||
}
|
||||
|
||||
func NewClassesSyncJob() *ClassesSyncJob {
|
||||
return &ClassesSyncJob{
|
||||
status: ClassesSyncStatus{UpdatedAt: time.Now().Unix()},
|
||||
}
|
||||
}
|
||||
|
||||
func (j *ClassesSyncJob) Status() ClassesSyncStatus {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.status
|
||||
}
|
||||
|
||||
func (j *ClassesSyncJob) Start(db *gorm.DB, client *qldt.Client, token string) bool {
|
||||
j.mu.Lock()
|
||||
if j.status.Running {
|
||||
j.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
j.status = ClassesSyncStatus{
|
||||
Running: true,
|
||||
Done: false,
|
||||
Total: 0,
|
||||
Synced: 0,
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
j.mu.Unlock()
|
||||
|
||||
go j.run(db, client, token)
|
||||
return true
|
||||
}
|
||||
|
||||
func (j *ClassesSyncJob) run(db *gorm.DB, client *qldt.Client, token string) {
|
||||
defer func() {
|
||||
j.mu.Lock()
|
||||
j.status.Running = false
|
||||
j.status.Done = j.status.Error == ""
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
j.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// 1. Lấy toàn bộ lớp học từ hệ thống chính (System ID = 0 đại diện cho tất cả)
|
||||
res, err := client.GetClassesBySystem(ctx, token, 0)
|
||||
if err != nil {
|
||||
j.setError("Failed to fetch classes: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
j.mu.Lock()
|
||||
j.status.Total = len(res.Data)
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
j.mu.Unlock()
|
||||
|
||||
var syncedClassIDs []int64
|
||||
for _, c := range res.Data {
|
||||
syncedClassIDs = append(syncedClassIDs, c.ID)
|
||||
}
|
||||
|
||||
// Đồng bộ từng lớp
|
||||
for idx, cl := range res.Data {
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
j.setError("Failed to start transaction: " + tx.Error.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Map thông tin class
|
||||
classRow := models.Class{
|
||||
RkID: cl.ID,
|
||||
Name: cl.Name,
|
||||
ClassCode: cl.ClassCode,
|
||||
Type: cl.Type,
|
||||
StudentCount: cl.StudentCount,
|
||||
}
|
||||
|
||||
if cl.Specializes != nil {
|
||||
specID := cl.Specializes.ID
|
||||
specName := cl.Specializes.Name
|
||||
classRow.SpecializeRkID = &specID
|
||||
classRow.SpecializeName = &specName
|
||||
if cl.Specializes.Systems != nil {
|
||||
sysID := cl.Specializes.Systems.ID
|
||||
sysCode := cl.Specializes.Systems.SystemCode
|
||||
sysName := cl.Specializes.Systems.Name
|
||||
classRow.SystemRkID = &sysID
|
||||
classRow.SystemCode = &sysCode
|
||||
classRow.SystemName = &sysName
|
||||
}
|
||||
}
|
||||
|
||||
// Xem lớp học hiện tại trong DB
|
||||
var existing models.Class
|
||||
err := tx.Where("rk_id = ?", cl.ID).First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
classRow.IsStudying = false // Lớp mới mặc định chưa học
|
||||
if err := tx.Create(&classRow).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to insert class: " + err.Error())
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Lookup class failed: " + err.Error())
|
||||
return
|
||||
} else {
|
||||
// Giữ trạng thái IsStudying cũ
|
||||
classRow.ID = existing.ID
|
||||
classRow.IsStudying = existing.IsStudying
|
||||
classRow.CreatedAt = existing.CreatedAt
|
||||
if err := tx.Save(&classRow).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to update class: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Lưu liên kết Course
|
||||
if err := tx.Where("class_rk_id = ?", cl.ID).Delete(&models.ClassCourseLink{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to clear old class course links: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var courseLinks []models.ClassCourseLink
|
||||
for ord, co := range cl.Courses {
|
||||
if co.ID == 0 {
|
||||
continue
|
||||
}
|
||||
courseLinks = append(courseLinks, models.ClassCourseLink{
|
||||
ClassRkID: cl.ID,
|
||||
CourseRkID: co.ID,
|
||||
CourseName: co.Name,
|
||||
CourseOrder: ord,
|
||||
})
|
||||
}
|
||||
if len(courseLinks) > 0 {
|
||||
if err := tx.Create(&courseLinks).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to insert class course links: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Đồng bộ sinh viên của lớp: Gọi class dashboard để trích xuất sinh viên
|
||||
// Ta chọn course đầu tiên của lớp để kéo thông tin sinh viên
|
||||
var classStudentsToMap []models.ClassStudent
|
||||
if len(cl.Courses) > 0 && cl.Courses[0].ID > 0 {
|
||||
dashboardJSON, err := client.GetClassDashboard(ctx, token, cl.ID, cl.Courses[0].ID)
|
||||
if err != nil {
|
||||
// Nếu lấy dashboard lỗi thì log lại nhưng không dừng tiến trình sync lớp
|
||||
log.Printf("Warning: failed to get dashboard for class ID %d, course ID %d: %v", cl.ID, cl.Courses[0].ID, err)
|
||||
} else {
|
||||
stList, err := extractStudentsFromDashboard(dashboardJSON)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to parse students from dashboard class %d: %v", cl.ID, err)
|
||||
} else if len(stList) > 0 {
|
||||
// Upsert sinh viên vào table students
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "rk_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"student_code",
|
||||
"full_name",
|
||||
"phone",
|
||||
"email",
|
||||
"date_of_birth",
|
||||
"gender",
|
||||
"status",
|
||||
"location",
|
||||
"system_id",
|
||||
"system_name",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(&stList).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to save class students: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Lưu map Class-Student
|
||||
for _, st := range stList {
|
||||
classStudentsToMap = append(classStudentsToMap, models.ClassStudent{
|
||||
ClassRkID: cl.ID,
|
||||
StudentRkID: st.RkID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cập nhật map Class-Student trong DB (xóa map cũ, insert map mới)
|
||||
if err := tx.Where("class_rk_id = ?", cl.ID).Delete(&models.ClassStudent{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to clear old class students mapping: " + err.Error())
|
||||
return
|
||||
}
|
||||
if len(classStudentsToMap) > 0 {
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
DoNothing: true, // Tránh trùng lặp do lỗi nào đó
|
||||
}).Create(&classStudentsToMap).Error; err != nil {
|
||||
tx.Rollback()
|
||||
j.setError("Failed to map class students: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
j.setError("Transaction commit failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
j.mu.Lock()
|
||||
j.status.Synced = idx + 1
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
j.mu.Unlock()
|
||||
|
||||
time.Sleep(50 * time.Millisecond) // Thở tí tránh spam API
|
||||
}
|
||||
|
||||
// 2. Xóa các lớp học không còn tồn tại trên QLĐT
|
||||
if len(syncedClassIDs) > 0 {
|
||||
db.Where("rk_id NOT IN ?", syncedClassIDs).Delete(&models.Class{})
|
||||
} else {
|
||||
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Class{})
|
||||
}
|
||||
}
|
||||
|
||||
func (j *ClassesSyncJob) setError(msg string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
j.status.Error = msg
|
||||
j.status.UpdatedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Helper giải nén danh sách sinh viên từ Dashboard JSON
|
||||
func extractStudentsFromDashboard(payloadJSON []byte) ([]models.Student, error) {
|
||||
var root interface{}
|
||||
if err := json.Unmarshal(payloadJSON, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var arr []interface{}
|
||||
if m, ok := root.(map[string]interface{}); ok {
|
||||
if dataVal, exists := m["data"]; exists {
|
||||
if dArr, ok := dataVal.([]interface{}); ok {
|
||||
arr = dArr
|
||||
} else if dMap, ok := dataVal.(map[string]interface{}); ok {
|
||||
for _, k := range []string{"studentsInClass", "students", "rows", "items"} {
|
||||
if val, exists := dMap[k]; exists {
|
||||
if innerArr, ok := val.([]interface{}); ok {
|
||||
arr = innerArr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
for _, k := range []string{"studentsInClass", "students", "rows", "items"} {
|
||||
if val, exists := m[k]; exists {
|
||||
if dArr, ok := val.([]interface{}); ok {
|
||||
arr = dArr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if sliceVal, ok := root.([]interface{}); ok {
|
||||
arr = sliceVal
|
||||
}
|
||||
|
||||
var students []models.Student
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok || m == nil {
|
||||
continue
|
||||
}
|
||||
studentObj, ok := m["student"].(map[string]interface{})
|
||||
if !ok || studentObj == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var rkID int64
|
||||
if idVal, exists := studentObj["id"]; exists {
|
||||
switch v := idVal.(type) {
|
||||
case float64:
|
||||
rkID = int64(v)
|
||||
case json.Number:
|
||||
if id64, err := v.Int64(); err == nil {
|
||||
rkID = id64
|
||||
}
|
||||
case int:
|
||||
rkID = int64(v)
|
||||
case int64:
|
||||
rkID = v
|
||||
}
|
||||
}
|
||||
if rkID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
stCode, _ := studentObj["studentCode"].(string)
|
||||
fullName, _ := studentObj["fullName"].(string)
|
||||
email, _ := studentObj["email"].(string)
|
||||
|
||||
var phone *string
|
||||
if p, ok := studentObj["phone"].(string); ok && p != "" {
|
||||
phone = &p
|
||||
}
|
||||
|
||||
var dob *time.Time
|
||||
if dobStr, ok := studentObj["dateOfBirth"].(string); ok && dobStr != "" {
|
||||
if t, err := time.Parse("2006-01-02", dobStr); err == nil {
|
||||
dob = &t
|
||||
}
|
||||
}
|
||||
|
||||
var gender *int
|
||||
if gVal, exists := studentObj["gender"]; exists {
|
||||
var g int
|
||||
switch v := gVal.(type) {
|
||||
case float64:
|
||||
g = int(v)
|
||||
case json.Number:
|
||||
if i64, err := v.Int64(); err == nil {
|
||||
g = int(i64)
|
||||
}
|
||||
case int:
|
||||
g = v
|
||||
}
|
||||
gender = &g
|
||||
}
|
||||
|
||||
status, _ := studentObj["status"].(string)
|
||||
location, _ := studentObj["location"].(string)
|
||||
|
||||
student := models.Student{
|
||||
RkID: rkID,
|
||||
StudentCode: stCode,
|
||||
FullName: fullName,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
DateOfBirth: dob,
|
||||
Gender: gender,
|
||||
Status: &status,
|
||||
Location: &location,
|
||||
}
|
||||
|
||||
if sysVal, ok := studentObj["system"].(map[string]interface{}); ok && sysVal != nil {
|
||||
var sysID int64
|
||||
if sID, exists := sysVal["id"]; exists {
|
||||
switch v := sID.(type) {
|
||||
case float64:
|
||||
sysID = int64(v)
|
||||
case int64:
|
||||
sysID = v
|
||||
}
|
||||
}
|
||||
sysName, _ := sysVal["name"].(string)
|
||||
if sysID > 0 {
|
||||
student.SystemID = &sysID
|
||||
student.SystemName = &sysName
|
||||
}
|
||||
}
|
||||
|
||||
students = append(students, student)
|
||||
}
|
||||
return students, nil
|
||||
}
|
||||
99
server/main.go
Normal file
99
server/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"server/internal/db"
|
||||
"server/internal/handlers"
|
||||
"server/internal/qldt"
|
||||
"server/internal/syncjobs"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Nạp file env nếu có
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Note: .env file not found or couldn't be loaded, using system environments.")
|
||||
}
|
||||
|
||||
// Đọc cấu hình database
|
||||
dbHost := getEnv("DB_HOST", "127.0.0.1")
|
||||
dbPort := getEnv("DB_PORT", "3306")
|
||||
dbUser := getEnv("DB_USER", "naruto")
|
||||
dbPass := getEnv("DB_PASSWORD", "29121999aA@")
|
||||
dbName := getEnv("DB_NAME", "simple_care")
|
||||
|
||||
// Đọc token và link QLĐT
|
||||
qldtToken := getEnv("QLDT_TOKEN", "")
|
||||
qldtBaseURL := getEnv("QLDT_BASE_URL", "https://apiportal.rikkei.edu.vn")
|
||||
qldtOrigin := getEnv("QLDT_ORIGIN", "https://qldt.rikkei.edu.vn")
|
||||
|
||||
if qldtToken == "" {
|
||||
log.Println("WARNING: QLDT_TOKEN is empty! Sync features will fail until QLDT_TOKEN is configured in environment.")
|
||||
}
|
||||
|
||||
// 1. Kết nối DB & Chạy Migration
|
||||
gormDB, err := db.ConnectDB(dbHost, dbPort, dbUser, dbPass, dbName)
|
||||
if err != nil {
|
||||
log.Fatalf("Database connection failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(gormDB); err != nil {
|
||||
log.Fatalf("Database migration failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Tạo Client QLĐT & Sync Jobs
|
||||
qldtClient := qldt.NewClient(qldtBaseURL, qldtOrigin)
|
||||
classesJob := syncjobs.NewClassesSyncJob()
|
||||
studentsJob := syncjobs.NewStudentsSyncJob()
|
||||
|
||||
// 3. Khởi tạo Fiber Web Server
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Simple Care Sync Backend",
|
||||
})
|
||||
|
||||
// Bật CORS để Client có thể gọi API mà không bị chặn
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, PATCH, PUT, DELETE, OPTIONS",
|
||||
}))
|
||||
|
||||
// 4. Setup Routes
|
||||
api := app.Group("/api")
|
||||
|
||||
// Dashboard Stats
|
||||
api.Get("/stats", handlers.GetStatsHandler(gormDB))
|
||||
|
||||
// Classes endpoints
|
||||
api.Get("/classes", handlers.ListClassesHandler(gormDB))
|
||||
api.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
api.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
|
||||
// Students endpoints
|
||||
api.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
|
||||
// Sync endpoints
|
||||
api.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob, qldtToken))
|
||||
api.Get("/sync/classes/status", handlers.GetClassesSyncStatusHandler(classesJob))
|
||||
api.Post("/sync/students/start", handlers.StartStudentsSyncHandler(gormDB, qldtClient, studentsJob, qldtToken))
|
||||
api.Get("/sync/students/status", handlers.GetStudentsSyncStatusHandler(studentsJob))
|
||||
|
||||
// Chạy app ở port 8080
|
||||
port := getEnv("PORT", "8080")
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
if err := app.Listen(":" + port); err != nil {
|
||||
log.Fatalf("Failed to start web server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
Reference in New Issue
Block a user