All checks were successful
Deploy on Master Change / deploy (push) Successful in 49s
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { DashboardTab } from './components/DashboardTab';
|
|
import { ClassesTab } from './components/ClassesTab';
|
|
import { StudentsTab } from './components/StudentsTab';
|
|
import { LearningTab } from './components/LearningTab';
|
|
import { ExamsTab } from './components/ExamsTab';
|
|
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
|
import { SystemTab } from './components/SystemTab';
|
|
import { MyAccountTab } from './components/AccountsTab';
|
|
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
|
import { ApplicationsTab } from './components/ApplicationsTab';
|
|
import { ChatWidget } from './components/ChatWidget';
|
|
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
|
import { ClassWorkspace } from './components/ClassWorkspace';
|
|
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
|
import {
|
|
goBack,
|
|
navigate,
|
|
navigateSystem,
|
|
parseRoute,
|
|
pushNav,
|
|
SYSTEM_SECTION_LABELS,
|
|
TAB_LABELS,
|
|
type SystemSection,
|
|
type TabId,
|
|
} from './navigation';
|
|
import { useAuth } from './auth/AuthContext';
|
|
|
|
const IconDashboard = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
|
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
|
</svg>
|
|
);
|
|
|
|
const IconClass = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
<polyline points="9 22 9 12 15 12 15 22" />
|
|
</svg>
|
|
);
|
|
|
|
const IconStudent = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
);
|
|
|
|
const IconLearning = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
|
</svg>
|
|
);
|
|
|
|
const IconExam = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
|
|
</svg>
|
|
);
|
|
|
|
const IconSystem = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
|
</svg>
|
|
);
|
|
|
|
const IconStudentAffairs = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
|
</svg>
|
|
);
|
|
|
|
const IconApplications = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
<path d="M3 14h7v7H3z" />
|
|
</svg>
|
|
);
|
|
|
|
const IconMenu = () => (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
);
|
|
|
|
const SYSTEM_NAV: { section: SystemSection }[] = [
|
|
{ section: 'organization' },
|
|
{ section: 'network' },
|
|
{ section: 'templates' },
|
|
{ section: 'seating' },
|
|
];
|
|
|
|
function App() {
|
|
const route = useRoute();
|
|
const { staff, logout } = useAuth();
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
return localStorage.getItem('sc_sidebar_collapsed') === 'true';
|
|
});
|
|
|
|
const toggleSidebarCollapse = () => {
|
|
// On mobile (<=900px), just close the sidebar overlay
|
|
if (window.innerWidth <= 900) {
|
|
setSidebarOpen(false);
|
|
return;
|
|
}
|
|
// On desktop, toggle collapsed state
|
|
const nextVal = !sidebarCollapsed;
|
|
setSidebarCollapsed(nextVal);
|
|
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
|
|
};
|
|
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>(() => {
|
|
try {
|
|
const stored = localStorage.getItem('sc_my_classes_details');
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleUpdate = () => {
|
|
try {
|
|
const stored = localStorage.getItem('sc_my_classes_details');
|
|
setMyClasses(stored ? JSON.parse(stored) : []);
|
|
} catch (e) { }
|
|
};
|
|
window.addEventListener('my-classes-updated', handleUpdate);
|
|
return () => window.removeEventListener('my-classes-updated', handleUpdate);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const initial = parseRoute();
|
|
if (initial.classId || initial.examId) {
|
|
return;
|
|
}
|
|
const label =
|
|
initial.tab === 'system'
|
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[initial.systemSection]}`
|
|
: TAB_LABELS[initial.tab];
|
|
pushNav({
|
|
kind: 'tab',
|
|
tab: initial.tab,
|
|
systemSection: initial.tab === 'system' ? initial.systemSection : undefined,
|
|
label,
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setSidebarOpen(false);
|
|
}, [route.tab, route.classId, route.examId, route.systemSection]);
|
|
|
|
const setActiveTab = (tab: string) => {
|
|
navigate(tab as TabId);
|
|
setSidebarOpen(false);
|
|
};
|
|
|
|
const handleBackFromWorkspace = () => {
|
|
goBack(route.tab);
|
|
};
|
|
|
|
const inWorkspace = Boolean(route.classId || route.examId);
|
|
|
|
const classesActive = route.tab === 'classes';
|
|
const learningActive = route.tab === 'learning';
|
|
const examsActive = route.tab === 'exams' || route.examId != null;
|
|
const systemActive = route.tab === 'system' && !inWorkspace;
|
|
|
|
const goSystem = (section: SystemSection) => {
|
|
navigateSystem(section);
|
|
setSidebarOpen(false);
|
|
};
|
|
|
|
const navBtn = (active: boolean) => `nav-btn${active ? ' active' : ''}`;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="mobile-menu-btn"
|
|
aria-label="Mở menu"
|
|
onClick={() => setSidebarOpen(true)}
|
|
>
|
|
<IconMenu />
|
|
</button>
|
|
|
|
{sidebarOpen && (
|
|
<button
|
|
type="button"
|
|
className="sidebar-backdrop"
|
|
aria-label="Đóng menu"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}${sidebarCollapsed ? ' sidebar--collapsed' : ''}`}>
|
|
<div className="brand">
|
|
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
|
<div className="brand-text">
|
|
<span className="brand-name">Simple Care</span>
|
|
<span className="brand-sub">Rikkei Education</span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav>
|
|
<ul className="nav-links">
|
|
<div className="nav-header">Tổng quan</div>
|
|
<li className="nav-item">
|
|
<button
|
|
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
|
|
onClick={() => setActiveTab('dashboard')}
|
|
>
|
|
<span className="nav-icon"><IconDashboard /></span>
|
|
Tổng quan & Tài Liệu
|
|
</button>
|
|
</li>
|
|
|
|
<div className="nav-header">CSDL liên kết</div>
|
|
<li className="nav-item">
|
|
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
|
<span className="nav-icon"><IconClass /></span>
|
|
Lớp học
|
|
</button>
|
|
</li>
|
|
<li className="nav-item">
|
|
<button
|
|
className={navBtn(route.tab === 'students' && !inWorkspace)}
|
|
onClick={() => setActiveTab('students')}
|
|
>
|
|
<span className="nav-icon"><IconStudent /></span>
|
|
Sinh viên
|
|
</button>
|
|
</li>
|
|
|
|
<div className="nav-header">Quản lý học tập</div>
|
|
<li className="nav-item">
|
|
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
|
<span className="nav-icon"><IconLearning /></span>
|
|
Phòng Học
|
|
</button>
|
|
</li>
|
|
<li className="nav-item">
|
|
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
|
<span className="nav-icon"><IconExam /></span>
|
|
Phòng thi
|
|
</button>
|
|
</li>
|
|
|
|
{myClasses.length > 0 && (
|
|
<>
|
|
<div className="nav-header">Lớp của tôi</div>
|
|
{myClasses.map((c) => (
|
|
<li key={c.id} className="nav-item">
|
|
<button
|
|
className={navBtn(route.classId === c.id && route.tab === 'learning')}
|
|
onClick={() => {
|
|
navigate('learning', c.id, c.name, 'class');
|
|
setSidebarOpen(false);
|
|
}}
|
|
title={c.name}
|
|
style={{
|
|
paddingLeft: '1.75rem',
|
|
fontSize: '0.82rem',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
display: 'block',
|
|
width: '100%',
|
|
textAlign: 'left'
|
|
}}
|
|
>
|
|
🏫 {c.code || c.name}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<div className="nav-header">Công tác sinh viên</div>
|
|
<li className="nav-item">
|
|
<button
|
|
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
|
|
onClick={() => setActiveTab('student-affairs')}
|
|
>
|
|
<span className="nav-icon"><IconStudentAffairs /></span>
|
|
Công Tác Sinh Viên
|
|
</button>
|
|
</li>
|
|
<li className="nav-item">
|
|
<button
|
|
className={navBtn(route.tab === 'applications' && !inWorkspace)}
|
|
onClick={() => setActiveTab('applications')}
|
|
>
|
|
<span className="nav-icon"><IconApplications /></span>
|
|
Ứng Dụng
|
|
</button>
|
|
</li>
|
|
|
|
<div className="nav-header">Hệ thống</div>
|
|
<li className="nav-item">
|
|
<button
|
|
className={navBtn(systemActive)}
|
|
onClick={() => goSystem('organization')}
|
|
>
|
|
<span className="nav-icon"><IconSystem /></span>
|
|
Quản lý hệ thống
|
|
</button>
|
|
</li>
|
|
<ul className="nav-sub">
|
|
{SYSTEM_NAV.map(({ section }) => (
|
|
<li key={section} className="nav-item">
|
|
<button
|
|
className={navBtn(systemActive && route.systemSection === section)}
|
|
onClick={() => goSystem(section)}
|
|
>
|
|
{SYSTEM_SECTION_LABELS[section]}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</ul>
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
<button
|
|
type="button"
|
|
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('profile')}
|
|
title="Tài khoản của tôi"
|
|
>
|
|
<span className="sidebar-user-name">
|
|
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
|
|
</span>
|
|
<span className="sidebar-user-email">{staff?.email}</span>
|
|
</button>
|
|
<button type="button" className="link-btn" onClick={logout}>
|
|
Đăng xuất
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="sidebar-edge-toggle"
|
|
onClick={toggleSidebarCollapse}
|
|
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
|
|
>
|
|
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor">
|
|
{sidebarCollapsed
|
|
? <path d="M1 1 L9 9 L1 17" />
|
|
: <path d="M9 1 L1 9 L9 17" />
|
|
}
|
|
</svg>
|
|
</button>
|
|
</aside>
|
|
|
|
<main className={`main-content${sidebarCollapsed ? ' main-content--full' : ''}`}>
|
|
<div className="page-viewport">
|
|
{route.classId ? (
|
|
<ClassWorkspace
|
|
classId={route.classId}
|
|
sourceTab={route.tab}
|
|
onBack={handleBackFromWorkspace}
|
|
renderNavBar={(className) => (
|
|
<NavHistoryBar classLabel={className} onBack={handleBackFromWorkspace} />
|
|
)}
|
|
/>
|
|
) : route.examId ? (
|
|
<ExamRoomWorkspace examId={route.examId} onBack={handleBackFromWorkspace} />
|
|
) : (
|
|
<>
|
|
{route.tab === 'dashboard' && <DashboardTab onNavigate={setActiveTab} />}
|
|
{route.tab === 'classes' && <ClassesTab />}
|
|
{route.tab === 'students' && <StudentsTab />}
|
|
{route.tab === 'learning' && <LearningTab />}
|
|
{route.tab === 'exams' && <ExamsTab />}
|
|
{route.tab === 'system' && <SystemTab />}
|
|
{route.tab === 'profile' && <MyAccountTab />}
|
|
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
|
{route.tab === 'applications' && <ApplicationsTab />}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
<StaffChatSocket />
|
|
<ChatWidget />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default App;
|