This commit is contained in:
@@ -101,6 +101,25 @@ function App() {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { staff, logout } = useAuth();
|
const { staff, logout } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const initial = parseRoute();
|
const initial = parseRoute();
|
||||||
@@ -219,6 +238,36 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</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>
|
<div className="nav-header">Công tác sinh viên</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ScheduleEditor } from './ScheduleEditor';
|
|||||||
import { AttendancePanel } from './AttendancePanel';
|
import { AttendancePanel } from './AttendancePanel';
|
||||||
import { StudentAvatar } from './StudentAvatar';
|
import { StudentAvatar } from './StudentAvatar';
|
||||||
import { StudentDetailModal } from './StudentDetailModal';
|
import { StudentDetailModal } from './StudentDetailModal';
|
||||||
|
import { ExamGridProctor } from './ExamGridProctor';
|
||||||
import { AppPoolModal } from './AppPoolModal';
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||||
import { mergeKeywordCSV } from '../utils/appKeywords';
|
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
@@ -50,7 +51,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
const [savingApps, setSavingApps] = useState(false);
|
const [savingApps, setSavingApps] = useState(false);
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'logs' | 'attendance'>('roster');
|
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance'>('roster');
|
||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
||||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||||
@@ -395,6 +396,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
>
|
>
|
||||||
Sơ đồ học viên
|
Sơ đồ học viên
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-sub-btn ${activeSubTab === 'grid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSubTab('grid')}
|
||||||
|
>
|
||||||
|
Giám sát camera 🖥️
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
|
className={`tab-sub-btn ${activeSubTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveSubTab('logs')}
|
onClick={() => setActiveSubTab('logs')}
|
||||||
@@ -411,7 +418,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Student Search */}
|
{/* Student Search */}
|
||||||
{activeSubTab !== 'attendance' && <div className="search-input-wrapper">
|
{activeSubTab !== 'attendance' && activeSubTab !== 'grid' && <div className="search-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="search-input"
|
className="search-input"
|
||||||
@@ -426,9 +433,28 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
|
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' ? 'workspace-panel-fill' : ''}`}>
|
<div className={`workspace-panel-body ${activeSubTab === 'attendance' || activeSubTab === 'logs' || activeSubTab === 'grid' ? 'workspace-panel-fill' : ''}`}>
|
||||||
{activeSubTab === 'attendance' ? (
|
{activeSubTab === 'attendance' ? (
|
||||||
<AttendancePanel classId={classId} />
|
<AttendancePanel classId={classId} />
|
||||||
|
) : activeSubTab === 'grid' ? (
|
||||||
|
<ExamGridProctor
|
||||||
|
students={students.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
examRoomId: 0,
|
||||||
|
studentRkId: s.rkId,
|
||||||
|
fullName: s.fullName,
|
||||||
|
studentCode: s.studentCode,
|
||||||
|
email: s.email,
|
||||||
|
avatar: s.avatar,
|
||||||
|
paperTitle: '',
|
||||||
|
submitted: false,
|
||||||
|
}))}
|
||||||
|
onlineIds={onlineIds}
|
||||||
|
onSelectStudent={(s) => {
|
||||||
|
const orig = students.find((st) => st.rkId === s.studentRkId);
|
||||||
|
if (orig) setSelectedStudent(orig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : activeSubTab === 'roster' ? (
|
) : activeSubTab === 'roster' ? (
|
||||||
/* Student Roster Grid */
|
/* Student Roster Grid */
|
||||||
filteredStudents.length === 0 ? (
|
filteredStudents.length === 0 ? (
|
||||||
|
|||||||
@@ -41,6 +41,35 @@ export const LearningTab: React.FC = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
const [onlyMyClasses, setOnlyMyClasses] = useState<boolean>(false);
|
||||||
|
const [myClasses, setMyClasses] = useState<number[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sc_my_classes_details');
|
||||||
|
const list = stored ? JSON.parse(stored) : [];
|
||||||
|
return Array.isArray(list) ? list.map((item: any) => item.id) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMyClass = (classId: number, name: string, code: string) => {
|
||||||
|
let list: any[] = [];
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('sc_my_classes_details');
|
||||||
|
list = stored ? JSON.parse(stored) : [];
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const exists = list.some(item => item.id === classId);
|
||||||
|
let nextList;
|
||||||
|
if (exists) {
|
||||||
|
nextList = list.filter(item => item.id !== classId);
|
||||||
|
} else {
|
||||||
|
nextList = [...list, { id: classId, name, code }];
|
||||||
|
}
|
||||||
|
localStorage.setItem('sc_my_classes_details', JSON.stringify(nextList));
|
||||||
|
setMyClasses(nextList.map(item => item.id));
|
||||||
|
window.dispatchEvent(new Event('my-classes-updated'));
|
||||||
|
};
|
||||||
|
|
||||||
const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
|
const [configClass, setConfigClass] = useState<ActiveClassItem | null>(null);
|
||||||
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
const [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||||||
@@ -89,10 +118,13 @@ export const LearningTab: React.FC = () => {
|
|||||||
loadActiveClasses();
|
loadActiveClasses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredClasses = useMemo(
|
const filteredClasses = useMemo(() => {
|
||||||
() => filterActiveClasses(activeClasses, searchQuery, dayFilter),
|
const list = filterActiveClasses(activeClasses, searchQuery, dayFilter);
|
||||||
[activeClasses, searchQuery, dayFilter]
|
if (onlyMyClasses) {
|
||||||
);
|
return list.filter(c => myClasses.includes(c.rkId));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [activeClasses, searchQuery, dayFilter, onlyMyClasses, myClasses]);
|
||||||
|
|
||||||
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
const weeklyShifts = useMemo(() => countWeeklyShifts(activeClasses), [activeClasses]);
|
||||||
const todayClassCount = useMemo(
|
const todayClassCount = useMemo(
|
||||||
@@ -231,7 +263,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-page-body tab-page-scroll">
|
<div className="tab-page-body" style={{ gap: '1rem' }}>
|
||||||
<div className="learning-stats-row">
|
<div className="learning-stats-row">
|
||||||
<div className="learning-stat">
|
<div className="learning-stat">
|
||||||
<span className="learning-stat-value">{activeClasses.length}</span>
|
<span className="learning-stat-value">{activeClasses.length}</span>
|
||||||
@@ -260,6 +292,14 @@ export const LearningTab: React.FC = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.88rem', fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', userSelect: 'none', marginLeft: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyMyClasses}
|
||||||
|
onChange={e => setOnlyMyClasses(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>⭐ Lớp của tôi</span>
|
||||||
|
</label>
|
||||||
<div className="learning-view-toggle">
|
<div className="learning-view-toggle">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -299,6 +339,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-page-scroll">
|
||||||
<div className="content-card">
|
<div className="content-card">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||||
@@ -334,6 +375,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setDayFilter(null);
|
setDayFilter(null);
|
||||||
|
setOnlyMyClasses(false);
|
||||||
}}
|
}}
|
||||||
style={{ marginTop: '0.5rem' }}
|
style={{ marginTop: '0.5rem' }}
|
||||||
>
|
>
|
||||||
@@ -349,12 +391,35 @@ export const LearningTab: React.FC = () => {
|
|||||||
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
||||||
<div className="learning-class-grid">
|
<div className="learning-class-grid">
|
||||||
{filteredClasses.map(cls => {
|
{filteredClasses.map(cls => {
|
||||||
const byDay = schedulesByDay(cls);
|
|
||||||
const today = classHasToday(cls);
|
const today = classHasToday(cls);
|
||||||
|
const isMyClass = myClasses.includes(cls.rkId);
|
||||||
return (
|
return (
|
||||||
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||||||
<header className="learning-class-card-head">
|
<header className="learning-class-card-head">
|
||||||
<div>
|
<div style={{ display: 'flex', gap: '0.65rem', alignItems: 'flex-start', flex: 1, minWidth: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn-my-class-star ${isMyClass ? 'active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMyClass(cls.rkId, cls.name, cls.classCode);
|
||||||
|
}}
|
||||||
|
title={isMyClass ? 'Xóa khỏi Lớp của tôi' : 'Thêm vào Lớp của tôi'}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
color: isMyClass ? '#eab308' : '#d1d5db',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
marginTop: '0.1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMyClass ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<div className="learning-class-code">{cls.classCode}</div>
|
<div className="learning-class-code">{cls.classCode}</div>
|
||||||
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
||||||
{(cls.specializeName || cls.systemName) && (
|
{(cls.specializeName || cls.systemName) && (
|
||||||
@@ -363,7 +428,8 @@ export const LearningTab: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="learning-class-badges">
|
</div>
|
||||||
|
<div className="learning-class-badges" style={{ flexShrink: 0 }}>
|
||||||
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
||||||
{cls.studentCount != null && cls.studentCount > 0 && (
|
{cls.studentCount != null && cls.studentCount > 0 && (
|
||||||
<span className="learning-badge">{cls.studentCount} SV</span>
|
<span className="learning-badge">{cls.studentCount} SV</span>
|
||||||
@@ -371,47 +437,15 @@ export const LearningTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="learning-class-schedule">
|
<footer className="learning-class-actions" style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||||
{byDay.size === 0 ? (
|
|
||||||
<p className="learning-class-no-sched">Chưa cấu hình ca học</p>
|
|
||||||
) : (
|
|
||||||
Array.from(byDay.entries()).map(([day, shifts]) => (
|
|
||||||
<div key={day} className="learning-schedule-day">
|
|
||||||
<span className="learning-schedule-day-label">{getDayLabel(day)}</span>
|
|
||||||
<div className="learning-schedule-shifts">
|
|
||||||
{shifts.map((s, idx) => (
|
|
||||||
<span key={idx} className="course-tag">
|
|
||||||
Ca{s.period || 1} {s.startTime && s.endTime ? `${s.startTime}–${s.endTime}` : ''}
|
|
||||||
{s.courseName ? ` · ${s.courseName}` : ''}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cls.allowedApps && (
|
|
||||||
<p className="learning-class-apps" title={cls.allowedApps}>
|
|
||||||
App: <code>{cls.allowedApps}</code>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="learning-class-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
onClick={() => handleOpenWorkspace(cls)}
|
onClick={() => handleOpenWorkspace(cls)}
|
||||||
>
|
>
|
||||||
Vào Workspace
|
Vào Workspace
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => handleOpenConfig(cls)}
|
|
||||||
>
|
|
||||||
Cấu hình
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||||
@@ -427,6 +461,7 @@ export const LearningTab: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{configClass && (
|
{configClass && (
|
||||||
<div className="modal-overlay" onClick={() => setConfigClass(null)}>
|
<div className="modal-overlay" onClick={() => setConfigClass(null)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user