This commit is contained in:
@@ -41,6 +41,35 @@ export const LearningTab: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dayFilter, setDayFilter] = useState<number | null>(null);
|
||||
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 [schedules, setSchedules] = useState<ClassScheduleItem[]>([]);
|
||||
@@ -89,10 +118,13 @@ export const LearningTab: React.FC = () => {
|
||||
loadActiveClasses();
|
||||
}, []);
|
||||
|
||||
const filteredClasses = useMemo(
|
||||
() => filterActiveClasses(activeClasses, searchQuery, dayFilter),
|
||||
[activeClasses, searchQuery, dayFilter]
|
||||
);
|
||||
const filteredClasses = useMemo(() => {
|
||||
const list = filterActiveClasses(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 todayClassCount = useMemo(
|
||||
@@ -231,7 +263,7 @@ export const LearningTab: React.FC = () => {
|
||||
</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-stat">
|
||||
<span className="learning-stat-value">{activeClasses.length}</span>
|
||||
@@ -260,6 +292,14 @@ export const LearningTab: React.FC = () => {
|
||||
value={searchQuery}
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -299,132 +339,127 @@ export const LearningTab: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card">
|
||||
{loading && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải dữ liệu...</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="tab-page-scroll">
|
||||
<div className="content-card">
|
||||
{loading && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải dữ liệu...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeClasses.length === 0 && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Chưa có lớp nào được tạo lịch học.</p>
|
||||
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '0.5rem' }}>
|
||||
Bắt đầu thêm lớp đầu tiên
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && activeClasses.length === 0 && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Chưa có lớp nào được tạo lịch học.</p>
|
||||
<button className="btn btn-secondary" onClick={handleOpenAddClass} style={{ marginTop: '0.5rem' }}>
|
||||
Bắt đầu thêm lớp đầu tiên
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setDayFilter(null);
|
||||
}}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
Xóa bộ lọc
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && activeClasses.length > 0 && filteredClasses.length === 0 && (
|
||||
<div className="learning-empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 1.5rem' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', fontWeight: 500 }}>Không tìm thấy lớp phù hợp với bộ lọc hiện tại.</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setDayFilter(null);
|
||||
setOnlyMyClasses(false);
|
||||
}}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
Xóa bộ lọc
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredClasses.length > 0 && viewMode === 'timetable' && (
|
||||
<LearningTimetable classes={filteredClasses} onOpenClass={handleOpenWorkspace} />
|
||||
)}
|
||||
{!loading && filteredClasses.length > 0 && viewMode === 'timetable' && (
|
||||
<LearningTimetable classes={filteredClasses} onOpenClass={handleOpenWorkspace} />
|
||||
)}
|
||||
|
||||
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
||||
<div className="learning-class-grid">
|
||||
{filteredClasses.map(cls => {
|
||||
const byDay = schedulesByDay(cls);
|
||||
const today = classHasToday(cls);
|
||||
return (
|
||||
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||||
<header className="learning-class-card-head">
|
||||
<div>
|
||||
<div className="learning-class-code">{cls.classCode}</div>
|
||||
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
||||
{(cls.specializeName || cls.systemName) && (
|
||||
<p className="learning-class-meta">
|
||||
{[cls.specializeName, cls.systemName].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="learning-class-badges">
|
||||
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
||||
{cls.studentCount != null && cls.studentCount > 0 && (
|
||||
<span className="learning-badge">{cls.studentCount} SV</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="learning-class-schedule">
|
||||
{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>
|
||||
{!loading && filteredClasses.length > 0 && viewMode === 'list' && (
|
||||
<div className="learning-class-grid">
|
||||
{filteredClasses.map(cls => {
|
||||
const today = classHasToday(cls);
|
||||
const isMyClass = myClasses.includes(cls.rkId);
|
||||
return (
|
||||
<article key={cls.rkId} className={`learning-class-card${today ? ' learning-class-card--today' : ''}`}>
|
||||
<header className="learning-class-card-head">
|
||||
<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>
|
||||
<h3 className="learning-class-name" title={cls.name}>{cls.name}</h3>
|
||||
{(cls.specializeName || cls.systemName) && (
|
||||
<p className="learning-class-meta">
|
||||
{[cls.specializeName, cls.systemName].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="learning-class-badges" style={{ flexShrink: 0 }}>
|
||||
{today && <span className="learning-badge learning-badge--today">Hôm nay</span>}
|
||||
{cls.studentCount != null && cls.studentCount > 0 && (
|
||||
<span className="learning-badge">{cls.studentCount} SV</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{cls.allowedApps && (
|
||||
<p className="learning-class-apps" title={cls.allowedApps}>
|
||||
App: <code>{cls.allowedApps}</code>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="learning-class-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleOpenWorkspace(cls)}
|
||||
>
|
||||
Vào Workspace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleOpenConfig(cls)}
|
||||
>
|
||||
Cấu hình
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
onClick={() => handleDeleteClass(cls)}
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<footer className="learning-class-actions" style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => handleOpenWorkspace(cls)}
|
||||
>
|
||||
Vào Workspace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
onClick={() => handleDeleteClass(cls)}
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user