diff --git a/management/package-lock.json b/management/package-lock.json index 53e941a..5c3b570 100644 --- a/management/package-lock.json +++ b/management/package-lock.json @@ -57,6 +57,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -266,29 +267,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -540,9 +518,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -560,9 +535,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -580,9 +552,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -600,9 +569,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -620,9 +586,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -640,9 +603,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -660,9 +620,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -680,9 +637,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -853,9 +807,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -873,9 +824,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -893,9 +841,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -913,9 +858,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -933,9 +875,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -953,9 +892,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1041,6 +977,7 @@ "integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "picomatch": "^4.0.4" }, @@ -1135,6 +1072,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1145,6 +1083,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1243,6 +1182,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -1298,6 +1238,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -1822,9 +1763,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1846,9 +1784,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1870,9 +1805,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1894,9 +1826,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2311,6 +2240,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2365,6 +2295,7 @@ "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" @@ -2648,6 +2579,7 @@ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/management/src/App.tsx b/management/src/App.tsx index b4c957b..7718997 100644 --- a/management/src/App.tsx +++ b/management/src/App.tsx @@ -28,67 +28,87 @@ import { import { useAuth } from './auth/AuthContext'; const IconDashboard = () => ( - - - + + + + + ); const IconClass = () => ( - + ); const IconStudent = () => ( - - - - + + + + + ); const IconLearning = () => ( - + ); const IconExam = () => ( - + - + + ); const IconSystem = () => ( - + - + ); const IconStudentAffairs = () => ( - + ); const IconApplications = () => ( - - - - - + + + + + +); + +const IconMyClass = () => ( + + + + +); + +const IconLogout = () => ( + + + + ); const IconMenu = () => ( - - + + + + ); @@ -210,122 +230,134 @@ function App() { - @@ -336,12 +368,24 @@ function App() { onClick={() => setActiveTab('profile')} title="Tài khoản của tôi" > - - {staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'} + + {(staff?.fullName?.trim() || staff?.email || '?') + .split(/\s+/) + .filter(Boolean) + .slice(-2) + .map((w) => w[0]?.toUpperCase() || '') + .join('') + .slice(0, 2) || '?'} + + + + {staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'} + + {staff?.email} - {staff?.email} - @@ -352,7 +396,7 @@ function App() { onClick={toggleSidebarCollapse} title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'} > - + {sidebarCollapsed ? : diff --git a/management/src/components/AppPoolModal.tsx b/management/src/components/AppPoolModal.tsx index f43c4ad..4d16c58 100644 --- a/management/src/components/AppPoolModal.tsx +++ b/management/src/components/AppPoolModal.tsx @@ -9,6 +9,48 @@ interface AppPoolModalProps { allowedApps: string; } +type IconProps = { size?: number }; + +const IconBox = ({ size = 20 }: IconProps) => ( + + + + +); + +const IconSearch = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconRefresh = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconPlus = ({ size = 13 }: IconProps) => ( + + + +); + +const IconCheck = ({ size = 12 }: IconProps) => ( + + + +); + +const IconInbox = ({ size = 32 }: IconProps) => ( + + + + +); + const formatWhen = (iso: string) => { if (!iso) return '—'; const d = new Date(iso); @@ -80,44 +122,63 @@ export const AppPoolModal: React.FC = ({ return (
-
e.stopPropagation()}> -
-
-

Kho ứng dụng

+
e.stopPropagation()}> +
+
+

+ + + + Kho ứng dụng +

- Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist lớp hiện tại. + Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist hiện tại.

- +
- setSearch(e.target.value)} - autoFocus - /> +
+ + + + setSearch(e.target.value)} + autoFocus + /> +
{error ? ( -
{error}
+
{error}
) : loading && items.length === 0 ? ( -
Đang tải...
+
+
+

Đang tải kho ứng dụng...

+
) : items.length === 0 ? ( -
- {debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'} +
+ +

+ {debouncedQ ? `Không tìm thấy “${debouncedQ}”` : 'Chưa có app nào trong kho.'} +

) : ( @@ -128,7 +189,7 @@ export const AppPoolModal: React.FC = ({ - + @@ -136,17 +197,33 @@ export const AppPoolModal: React.FC = ({ const added = allowedSet.has(app.keyword.toLowerCase()); return ( - - - - - + + + + + @@ -159,7 +236,9 @@ export const AppPoolModal: React.FC = ({
- Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải) + Hiển thị {items.length} kết quả + {debouncedQ ? ` cho “${debouncedQ}”` : ''} + {' '}(tối đa 80 mỗi lần tải)
diff --git a/management/src/components/AppTemplatePickerModal.tsx b/management/src/components/AppTemplatePickerModal.tsx index 6ec9372..d7f6cb3 100644 --- a/management/src/components/AppTemplatePickerModal.tsx +++ b/management/src/components/AppTemplatePickerModal.tsx @@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps { allowedApps: string; } +type IconProps = { size?: number }; + +const IconLayers = ({ size = 20 }: IconProps) => ( + + + + + +); + +const IconSearch = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconRefresh = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconCheck = ({ size = 14 }: IconProps) => ( + + + +); + +const IconInbox = ({ size = 32 }: IconProps) => ( + + + + +); + +function keywordList(csv: string): string[] { + return csv.split(',').map((s) => s.trim()).filter(Boolean); +} + export const AppTemplatePickerModal: React.FC = ({ open, onClose, @@ -60,70 +101,110 @@ export const AppTemplatePickerModal: React.FC = ({ return (
-
e.stopPropagation()}> -
-
-

Chọn khung ứng dụng

+
e.stopPropagation()}> +
+
+

+ + + + Chọn khung ứng dụng +

- Ghép bộ keyword đã lưu vào danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng. + Ghép bộ keyword đã lưu vào whitelist. Quản lý khung tại Hệ thống → Khung ứng dụng.

-
- setSearch(e.target.value)} - autoFocus - /> -
{error ? ( -
{error}
+
{error}
) : loading && items.length === 0 ? ( -
Đang tải...
+
+
+

Đang tải khung ứng dụng...

+
) : filtered.length === 0 ? ( -
- {q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'} +
+ +

+ {q + ? `Không tìm thấy “${search}”` + : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'} +

) : (
- {filtered.map((tpl) => ( -
-
- {tpl.name} - {countKeywords(tpl.keywords)} keyword -
- {tpl.description &&

{tpl.description}

} - {tpl.keywords} - -
- ))} + {filtered.map((tpl) => { + const kws = keywordList(tpl.keywords); + const count = countKeywords(tpl.keywords); + return ( +
+
+
+ {tpl.name} + {count} keyword +
+ {tpl.description ? ( +

{tpl.description}

+ ) : null} +
+ {kws.map((kw) => ( + + {kw} + + ))} +
+
+ +
+ ); + })}
)}
- Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại + Whitelist hiện tại: {countKeywords(allowedApps)} keyword + {filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
diff --git a/management/src/components/AttendancePanel.tsx b/management/src/components/AttendancePanel.tsx index cbc3cd8..1991143 100644 --- a/management/src/components/AttendancePanel.tsx +++ b/management/src/components/AttendancePanel.tsx @@ -14,6 +14,98 @@ import { type LeaveRequestItem, } from '../api'; +/* ─── Icon components ─────────────────────────────────────────────────────── */ + +type IconProps = { size?: number; className?: string }; + +const IconClipboard = ({ size = 18 }: IconProps) => ( + + + + +); + +const IconRefresh = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconChevronDown = ({ size = 15 }: IconProps) => ( + + + +); + +const IconChevronUp = ({ size = 15 }: IconProps) => ( + + + +); + +const IconMail = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconUpload = ({ size = 15 }: IconProps) => ( + + + + + +); + +const IconClose = ({ size = 14 }: IconProps) => ( + + + + +); + +const IconInfo = ({ size = 15 }: IconProps) => ( + + + + + +); + +const IconCheck = ({ size = 14 }: IconProps) => ( + + + +); + +const IconXMark = ({ size = 14 }: IconProps) => ( + + + + +); + +const IconImage = ({ size = 13 }: IconProps) => ( + + + + + +); + +const IconSync = ({ size = 15 }: IconProps) => ( + + + + + + +); + +/* ─── Helpers ────────────────────────────────────────────────────────────── */ + interface AttendancePanelProps { classId: number; onClose?: () => void; @@ -29,6 +121,8 @@ function formatQldtTime(iso?: string): string { }); } +/* ─── Component ─────────────────────────────────────────────────────────── */ + export const AttendancePanel: React.FC = ({ classId, onClose }) => { const today = new Date().toISOString().slice(0, 10); const [date, setDate] = useState(today); @@ -47,11 +141,7 @@ export const AttendancePanel: React.FC = ({ classId, onClo const handleScroll = useCallback((e: React.UIEvent) => { const scrollTop = e.currentTarget.scrollTop; - if (scrollTop > 20) { - setIsCollapsed(true); - } else if (scrollTop <= 5) { - setIsCollapsed(false); - } + setIsCollapsed(scrollTop > 20); }, []); const loadShifts = useCallback(async () => { @@ -61,9 +151,7 @@ export const AttendancePanel: React.FC = ({ classId, onClo if (res.data?.length && !res.data.find((s: any) => s.period === period)) { setPeriod(res.data[0].period || 1); } - } catch { - setShifts([]); - } + } catch { setShifts([]); } }, [classId, date, period]); const loadAttendance = useCallback(async (isBackground: boolean | any = false) => { @@ -73,7 +161,7 @@ export const AttendancePanel: React.FC = ({ classId, onClo const res = await apiFetchAttendance(classId, date, period); setRows(res.data || []); setShiftInfo(res.shift || null); - if (!isBg) setSelectedStudentRkIds([]); // Clear selection when data changes + if (!isBg) setSelectedStudentRkIds([]); } catch (e: any) { alert(e.message || 'Không tải được điểm danh'); } finally { @@ -81,26 +169,16 @@ export const AttendancePanel: React.FC = ({ classId, onClo } }, [classId, date, period]); - const currentShift = useMemo(() => { - return shifts.find(s => s.period === period); - }, [shifts, period]); - + const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]); const courseId = currentShift?.courseId; const loadLeaveRequests = useCallback(async () => { - if (!courseId) { - setLeaveRequests([]); - return; - } + if (!courseId) { setLeaveRequests([]); return; } try { setLoadingLeave(true); const res = await apiFetchLeaveRequests(classId, courseId, date); setLeaveRequests(res || []); - } catch { - setLeaveRequests([]); - } finally { - setLoadingLeave(false); - } + } catch { setLeaveRequests([]); } finally { setLoadingLeave(false); } }, [classId, courseId, date]); useEffect(() => { loadShifts(); }, [loadShifts]); @@ -109,12 +187,7 @@ export const AttendancePanel: React.FC = ({ classId, onClo const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => { try { - await apiUpdateLeaveStatus(classId, leaveId, { - status, - studentRkId, - date, - period, - }); + await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period }); await loadAttendance(); await loadLeaveRequests(); } catch (e: any) { @@ -126,87 +199,61 @@ export const AttendancePanel: React.FC = ({ classId, onClo const q = searchQuery.trim().toLowerCase(); if (!q) return rows; return rows.filter(row => { - const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel] - .filter(Boolean) - .join(' ') - .toLowerCase(); + const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [rows, searchQuery]); const statusCounts = useMemo(() => { const counts: Record = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; - for (const row of rows) { - if (counts[row.status] !== undefined) counts[row.status]++; - } + for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; } return counts; }, [rows]); - const toggleStudentSelection = useCallback((studentRkId: number) => { + const toggleStudentSelection = (studentRkId: number) => { setSelectedStudentRkIds(prev => prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId] ); - }, []); + }; const handleStatusChange = async (studentRkId: number, status: number) => { - // Optimistic state update so the UI reacts instantly setRows(prevRows => prevRows.map(row => { if (row.studentRkId === studentRkId) { const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status); - return { - ...row, - status, - statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, - statusEditedByTeacher: true, - }; + return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true }; } return row; }) ); - try { await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status }); await loadAttendance(true); } catch (e: any) { alert(e.message || 'Cập nhật trạng thái thất bại'); - await loadAttendance(); // Rollback on failure + await loadAttendance(); } }; const handleBulkStatusChange = async (status: number) => { if (selectedStudentRkIds.length === 0) return; - - // Optimistic state update for selected students setRows(prevRows => prevRows.map(row => { if (selectedStudentRkIds.includes(row.studentRkId)) { const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status); - return { - ...row, - status, - statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, - statusEditedByTeacher: true, - }; + return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true }; } return row; }) ); - const idsToUpdate = [...selectedStudentRkIds]; setSelectedStudentRkIds([]); - try { - await apiUpdateAttendanceBulkStatus(classId, { - date, - period, - studentRkIds: idsToUpdate, - status, - }); + await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status }); await loadAttendance(true); } catch (e: any) { alert(e.message || 'Cập nhật hàng loạt thất bại'); - await loadAttendance(); // Rollback + await loadAttendance(); } }; @@ -223,202 +270,257 @@ export const AttendancePanel: React.FC = ({ classId, onClo await loadAttendance(); } catch (e: any) { alert(e.message || 'Đẩy QLĐT thất bại'); - } finally { - setPushing(false); - } + } finally { setPushing(false); } }; const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt); const qldtDirty = Boolean(shiftInfo?.qldtDirty); + const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ'); + return ( -
-
-
-
-

- 📋 Quản lý điểm danh ca học -

-
+
+ + + {/* ── Header ── */} +
+

+ + Điểm danh ca học +

+
-
- -
-
- - - - - - {courseId && ( - )} - -
- -
-
- {qldtSynced ? ( - qldtDirty ? ( - <> - Đã lưu QLĐT — có thay đổi mới - - Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa + {courseId && ( + + )} + + {onClose && ( + )}
+
+ {/* ── Collapsible toolbar area ── */} +
+ {/* Toolbar: date / shift / search */} +
+ + + +
+ + {/* QLĐT sync status banner */} +
+ {qldtSynced + ? qldtDirty + ? <>Đã lưu QLĐT — có thay đổi mớiLần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại + : <>Đã lưu QLĐTLần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} + : <>Chưa đẩy lên QLĐTCa {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra + } +
+ + {/* Shift info strip */} {currentShift && ( -
+
{currentShift.startTime}–{currentShift.endTime} {currentShift.courseName || 'Chưa chọn môn'} - {!currentShift.isActive && Ca tắt} + {!currentShift.isActive && Ca tắt}
)} -
+ {/* Status summary chips */} +
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( - ))} - - {filteredRows.length}/{rows.length} SV - {searchQuery.trim() ? ' (đã lọc)' : ''} - + {filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}
-

+

Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.

-
- 💡 Lưu ý: Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và KHÔNG tự động lưu lên QLĐT. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên, sau đó bấm nút Đẩy QLĐT ở góc phải bên trên để đồng bộ điểm danh chính thức. + {/* Notice banner — replaces yellow emoji block */} +
+ + + Lưu ý: Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và{' '} + KHÔNG tự động lưu lên QLĐT. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên, + sau đó bấm nút Đẩy QLĐT ở góc phải bên trên để đồng bộ điểm danh chính thức. +
- {/* Leave Requests Approval Modal */} + {/* ── Leave Requests Modal ── */} {showLeaveModal && courseId && (
setShowLeaveModal(false)}>
e.stopPropagation()}>
-

✉️ Đơn xin nghỉ phép trong ca

-

- Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ học. -

+

+ + Đơn xin nghỉ phép +

+

Ca {period} ngày {date}

- - +
-
-
💡 Lưu ý duyệt đơn phép: Thao tác duyệt/từ chối đơn tại đây chỉ được ghi nhận nội bộ trên hệ thống Simple Care (không đồng bộ ngược lên QLĐT).
-
• Khi duyệt đơn, hệ thống sẽ tự động đổi trạng thái điểm danh của sinh viên này thành "Nghỉ có phép".
-
• Trạng thái này sẽ được cập nhật lên QLĐT khi thầy cô bấm nút "Đẩy QLĐT" ở giao diện điểm danh ca học.
+ {/* Info notice */} +
+ +
+ Lưu ý duyệt đơn phép: Thao tác duyệt/từ chối đơn tại đây chỉ được ghi nhận{' '} + nội bộ trên hệ thống Simple Care (không đồng bộ ngược lên QLĐT). +
+ • Khi duyệt đơn, hệ thống sẽ tự động đổi trạng thái điểm danh thành "Nghỉ có phép".
+ • Trạng thái này sẽ được cập nhật lên QLĐT khi bấm nút "Đẩy QLĐT". +
+
+ {loadingLeave ? (
@@ -429,81 +531,43 @@ export const AttendancePanel: React.FC = ({ classId, onClo
) : ( leaveRequests.map((req) => ( -
+
{req.student.fullName}
{req.student.studentCode} - + {req.status}
- {req.status === 'Đang chờ' && (
)}
- -
+
Lý do nghỉ: {req.note || 'Không có ghi chú'}
- {req.reasonImage && ( -
- - 🖼️ Xem ảnh minh chứng phép + )} @@ -515,43 +579,34 @@ export const AttendancePanel: React.FC = ({ classId, onClo
)} + {/* ── Bulk status actions bar ── */} {selectedStudentRkIds.length > 0 && ( -
- - Đang chọn {selectedStudentRkIds.length} sinh viên: - -
+
+ Đang chọn {selectedStudentRkIds.length} SV: +
{ATTENDANCE_STATUS_OPTIONS.map(opt => ( - ))}
-
)} -
+ {/* ── Attendance table ── */} +
{loading ? ( -
+
+
+
) : ( -
Tiêu đề Lần chặn Gần nhấtThao tác
{app.keyword}{app.processName}{app.windowTitle || '—'}{app.hitCount}×{formatWhen(app.lastSeenAt)} + {app.keyword} + + {app.processName || '—'} + + {app.windowTitle || '—'} + + {app.hitCount}× + {formatWhen(app.lastSeenAt)} {added ? ( - Đã có + + + Đã có + ) : ( - )}
+
- {filteredRows.length === 0 ? ( - @@ -584,60 +639,38 @@ export const AttendancePanel: React.FC = ({ classId, onClo filteredRows.map(row => { const isSelected = selectedStudentRkIds.includes(row.studentRkId); return ( - - + - @@ -648,8 +681,6 @@ export const AttendancePanel: React.FC = ({ classId, onClo
+ 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))} @@ -576,7 +631,7 @@ export const AttendancePanel: React.FC = ({ classId, onClo
+ {rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
toggleStudentSelection(row.studentRkId)} - > - {}} - onClick={(e) => { - e.stopPropagation(); - toggleStudentSelection(row.studentRkId); - }} - /> +
+ toggleStudentSelection(row.studentRkId)} /> toggleStudentSelection(row.studentRkId)} - > -
{row.fullName}
-
+
toggleStudentSelection(row.studentRkId)}> +
{row.fullName}
+
{row.studentCode} {row.email && {row.email}}
- {row.onlineMinutes} - phút + {row.onlineMinutes} + phút -
- {row.pushedToQldtAt ? ( - - QLĐT ✓ - - ) : ( - Chưa QLĐT - )} - {row.statusEditedByTeacher && ( - - Đã khóa - - )} +
+ {row.pushedToQldtAt + ? QLĐT + : Chưa QLĐT + } + {row.statusEditedByTeacher && Đã khóa}
)}
-
-
); }; diff --git a/management/src/components/ClassWorkspace.tsx b/management/src/components/ClassWorkspace.tsx index 71f37fc..db260b8 100644 --- a/management/src/components/ClassWorkspace.tsx +++ b/management/src/components/ClassWorkspace.tsx @@ -19,7 +19,6 @@ import { BASE_APP_SUGGESTIONS } from '../constants'; import { pushNav, type TabId } from '../navigation'; import { ScheduleEditor } from './ScheduleEditor'; import { AttendancePanel } from './AttendancePanel'; - import { StudentDetailModal } from './StudentDetailModal'; import { ExamGridProctor } from './ExamGridProctor'; import { AppPoolModal } from './AppPoolModal'; @@ -29,6 +28,100 @@ import { AppTemplatePickerModal } from './AppTemplatePickerModal'; import { mergeKeywordCSV } from '../utils/appKeywords'; import { WorkspaceSeatingChart } from './WorkspaceSeatingChart'; +// ─── Icon Components ──────────────────────────────────────────────────────── +type IconProps = { size?: number }; + +const IconSchool = ({ size = 20 }: IconProps) => ( + + + + + +); + +const IconSettings = ({ size = 16 }: IconProps) => ( + + + + +); + +const IconClose = ({ size = 16 }: IconProps) => ( + + + +); + +const IconMonitor = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconSearch = ({ size = 15 }: IconProps) => ( + + + + +); + +const IconBarChart = ({ size = 40 }: IconProps) => ( + + + + +); + +const IconWifi = ({ size = 14 }: IconProps) => ( + + + + + + +); + +const IconUsers = ({ size = 14 }: IconProps) => ( + + + + + + +); + +const IconMap = ({ size = 15 }: IconProps) => ( + + + + + +); + +const IconLog = ({ size = 15 }: IconProps) => ( + + + + + + +); + +const IconShield = ({ size = 15 }: IconProps) => ( + + + +); + +const IconCheckSquare = ({ size = 15 }: IconProps) => ( + + + + +); +// ─── End Icon Components ──────────────────────────────────────────────────── + interface ClassWorkspaceProps { classId: number; sourceTab?: TabId; @@ -50,7 +143,6 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT const [logShifts, setLogShifts] = useState([]); const [selectedStudent, setSelectedStudent] = useState(null); - // Loading & tab states const [loading, setLoading] = useState(true); const [logsLoading, setLogsLoading] = useState(false); const [savingApps, setSavingApps] = useState(false); @@ -63,8 +155,6 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT const loadAllData = async () => { try { setLoading(true); - - // Fetch specific class detail directly const classData = await api.getClass(classId); setClassInfo(classData); pushNav({ @@ -73,33 +163,18 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT classId, label: classData.name || `Lớp ${classId}`, }); - - // Fetch students roster const studentsRes = await apiFetchClassStudents(classId); setStudents(studentsRes.data); - - // Fetch allowed apps try { const appsRes = await apiFetchAllowedApps(classId); setAllowedApps(appsRes.keywords || ''); - } catch (e) { - console.log("No allowed apps configured yet"); - } - - // Fetch schedules + } catch (e) { /* ignored */ } try { const schedRes = await apiFetchClassSchedule(classId); setSchedules(schedRes.data || []); - } catch (e) { - console.log("No schedule configured yet"); - } - - // Fetch online status + } catch (e) { /* ignored */ } await fetchOnlineStatus(); - - // Fetch logs await fetchLogs(); - } catch (err) { console.error(err); } finally { @@ -111,9 +186,7 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT try { const res = await apiFetchOnlineStudents(classId); setOnlineIds(res.onlineStudentIds || []); - } catch (e) { - console.error("Failed to load online students list", e); - } + } catch (e) { console.error(e); } }; const fetchLogs = async (date = logDate, period = logPeriod) => { @@ -122,20 +195,14 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT const res = await apiFetchClassSessionLogs(classId, date, period); setSessionLogs(res.data || []); if (res.period) setLogPeriod(res.period); - } catch (e) { - console.error("Failed to load session logs", e); - } finally { - setLogsLoading(false); - } + } catch (e) { console.error(e); } finally { setLogsLoading(false); } }; const loadLogShifts = async (date = logDate) => { try { const res = await apiFetchAttendanceShifts(classId, date); setLogShifts(res.data || []); - } catch { - setLogShifts([]); - } + } catch { setLogShifts([]); } }; useEffect(() => { @@ -215,13 +282,9 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT try { const schedRes = await apiFetchClassSchedule(classId); setSchedules(schedRes.data || []); - } catch { - setSchedules([]); - } + } catch { setSchedules([]); } }; - - const formatDuration = (totalSeconds: number) => { const hrs = Math.floor(totalSeconds / 3600); const mins = Math.floor((totalSeconds % 3600) / 60); @@ -242,7 +305,9 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT if (!classInfo) { return (
-
🏫
+
+ +
Không tìm thấy thông tin lớp học
@@ -250,35 +315,42 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT } const onlineCount = students.filter(st => onlineIds.includes(st.rkId)).length; - const getSessionLogForStudent = (rkId: number) => sessionLogs.find(log => log.studentRkId === rkId) || null; + const SUB_TABS: { key: typeof activeSubTab; label: string; icon: React.ReactNode }[] = [ + { key: 'roster', label: 'Sơ đồ', icon: }, + { key: 'grid', label: 'Giám sát', icon: }, + { key: 'logs', label: 'Nhật ký', icon: }, + { key: 'violations', label: 'Vi phạm', icon: }, + { key: 'attendance', label: 'Điểm danh',icon: }, + ]; + return (
{renderNavBar && classInfo && renderNavBar(classInfo.name)} - {/* Header section */} -
-
-
-
🏫
-
-

{classInfo.name}

-
- CODE: {classInfo.classCode} - ID: {classInfo.rkId} - {classInfo.systemName || 'Hệ thống'} -
+ {/* ===== HEADER ===== */} +
+
+
+ +
+
+

{classInfo.name}

+
+ CODE: {classInfo.classCode} + ID: {classInfo.rkId} + {classInfo.systemName || 'Hệ thống'}
-
-
-
- Trạng thái dạy - +
+
+
+ Trạng thái dạy + {classInfo.isStudying ? 'Đang học' : 'Tạm dừng'}
@@ -288,85 +360,91 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT
-
-
Online / Sĩ số
-
- {onlineCount} - / {students.length} SV +
+ Online / Sĩ số +
+ + {onlineCount} + / {students.length} SV
- {/* Main area */} -
- {configOpen && ( -
setConfigOpen(false)} aria-hidden /> - )} + {/* ===== MAIN AREA ===== */} +
-