This commit is contained in:
2026-06-29 23:45:08 +07:00
parent 5290eeeb91
commit c736326162
15 changed files with 3067 additions and 388 deletions

View File

@@ -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 */

View File

@@ -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
View 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();
},
};

View 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ớp Học</h1>
<p>Đng bộ dữ liệu lớp học 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"> 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> 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 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' }}>
lớp: {activeClass.classCode} | số: {activeClass.studentCount}
</p>
</div>
<button className="modal-close-btn" onClick={() => setActiveClass(null)}>
&times;
</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 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> 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>
);
};

View 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ộ quản 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 danh sách sinh viên nội bộ không lo nh hưởng đến dữ liệu hiện .
</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
Quản Lớp học
</button>
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
Danh sách Sinh viên
</button>
</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 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ớp:</b> Lọc danh sách lớp theo Phân hệ (System ID), Tìm kiếm tên hoặc lớp 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 thi cử.</li>
</ul>
</div>
</div>
</>
)}
</div>
);
};

View 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 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> 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>
);
};

View File

@@ -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
View 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
View 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
View 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{},
)
}

View 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,
})
}
}

View 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" }

View 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
}

View 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
View 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
}