All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
Reviewed-on: #1 Co-authored-by: Pham Hung <22.phamviethung.12a12@gmail.com> Co-committed-by: Pham Hung <22.phamviethung.12a12@gmail.com>
1498 lines
56 KiB
TypeScript
1498 lines
56 KiB
TypeScript
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||
import type { SeatingTemplate } from './SeatingTemplatesSection';
|
||
import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar';
|
||
import { apiSeatingLayout } from '../api';
|
||
|
||
// ── Inline SVG icons (stroke-only, matching LearningTab / ExamsTab style) ─────
|
||
|
||
type IconProps = { size?: number };
|
||
|
||
const IconChair = ({ size = 32 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M4 12V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6" />
|
||
<path d="M2 12h20" />
|
||
<path d="M6 12v5" />
|
||
<path d="M18 12v5" />
|
||
<path d="M4 17h16" />
|
||
<path d="M8 21v-4" />
|
||
<path d="M16 21v-4" />
|
||
</svg>
|
||
);
|
||
|
||
const IconGrid = ({ size = 14 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<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 IconList = ({ size = 14 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
|
||
</svg>
|
||
);
|
||
|
||
const IconZap = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
|
||
</svg>
|
||
);
|
||
|
||
const IconTrash = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M3 6h18" />
|
||
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||
<path d="M10 11v6M14 11v6" />
|
||
</svg>
|
||
);
|
||
|
||
const IconSettings = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||
</svg>
|
||
);
|
||
|
||
const IconCheck = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M20 6 9 17l-5-5" />
|
||
</svg>
|
||
);
|
||
|
||
const IconLock = ({ size = 14 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||
</svg>
|
||
);
|
||
|
||
const IconX = ({ size = 10 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M18 6 6 18M6 6l12 12" />
|
||
</svg>
|
||
);
|
||
|
||
const IconSearch = ({ size = 15 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<circle cx="11" cy="11" r="7" />
|
||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||
</svg>
|
||
);
|
||
|
||
const IconFileText = ({ size = 11 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<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" />
|
||
<polyline points="10 9 9 9 8 9" />
|
||
</svg>
|
||
);
|
||
|
||
const IconBoard = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||
<path d="M8 21h8M12 17v4" />
|
||
</svg>
|
||
);
|
||
|
||
const IconTemplate = ({ size = 15 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||
<path d="M3 9h18M9 21V9" />
|
||
</svg>
|
||
);
|
||
|
||
const IconSliders = ({ size = 15 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<line x1="4" y1="6" x2="20" y2="6" />
|
||
<line x1="4" y1="12" x2="20" y2="12" />
|
||
<line x1="4" y1="18" x2="20" y2="18" />
|
||
<circle cx="8" cy="6" r="2" fill="currentColor" stroke="none" />
|
||
<circle cx="16" cy="12" r="2" fill="currentColor" stroke="none" />
|
||
<circle cx="10" cy="18" r="2" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
);
|
||
|
||
const IconRefresh = ({ size = 13 }: IconProps) => (
|
||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
);
|
||
|
||
// ── Component ─────────────────────────────────────────────────────────────────
|
||
|
||
interface WorkspaceSeatingChartProps {
|
||
workspaceId: string;
|
||
students: {
|
||
rkId: number;
|
||
fullName: string;
|
||
studentCode: string;
|
||
avatar?: string;
|
||
paperTitle?: string;
|
||
}[];
|
||
onlineIds: number[];
|
||
onStudentClick?: (studentRkId: number) => void;
|
||
}
|
||
|
||
interface SeatingLayout {
|
||
rows: number;
|
||
cols: number;
|
||
boardPosition: 'top' | 'bottom' | 'left' | 'right';
|
||
lockedSeats: string[];
|
||
seats: Record<string, number>;
|
||
}
|
||
|
||
export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
||
workspaceId,
|
||
students,
|
||
onlineIds,
|
||
onStudentClick,
|
||
}) => {
|
||
const [layout, setLayout] = useState<SeatingLayout | null>(null);
|
||
const [templates, setTemplates] = useState<SeatingTemplate[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [setupMode, setSetupMode] = useState<'template' | 'custom'>('template');
|
||
const [customRows, setCustomRows] = useState(5);
|
||
const [customCols, setCustomCols] = useState(6);
|
||
const [customBoard, setCustomBoard] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||
const [isEditingStructure, setIsEditingStructure] = useState(false);
|
||
const [editRows, setEditRows] = useState(5);
|
||
const [editCols, setEditCols] = useState(6);
|
||
const [editBoardPos, setEditBoardPos] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
||
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||
const [saveNoticeKind, setSaveNoticeKind] = useState<'success' | 'error'>('success');
|
||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||
const [listSearchQuery, setListSearchQuery] = useState('');
|
||
|
||
const workspaceInfo = useMemo(() => {
|
||
if (workspaceId.startsWith('class-')) {
|
||
const n = Number(workspaceId.replace('class-', ''));
|
||
return isNaN(n) ? null : { kind: 'class' as const, id: n };
|
||
}
|
||
if (workspaceId.startsWith('exam-')) {
|
||
const n = Number(workspaceId.replace('exam-', ''));
|
||
return isNaN(n) ? null : { kind: 'exam' as const, id: n };
|
||
}
|
||
return null;
|
||
}, [workspaceId]);
|
||
|
||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const pendingLayoutRef = useRef<SeatingLayout | null | undefined>(undefined);
|
||
const legacyLayoutKey = `sc_seating_layout_${workspaceId}`;
|
||
|
||
const showSaveNotice = (message: string, kind: 'success' | 'error' = 'success') => {
|
||
setSaveNoticeKind(kind);
|
||
setSaveNotice(message);
|
||
};
|
||
|
||
const persistLayout = async (newLayout: SeatingLayout | null) => {
|
||
if (!workspaceInfo) return;
|
||
try {
|
||
if (newLayout) {
|
||
const json = JSON.stringify(newLayout);
|
||
const saver = workspaceInfo.kind === 'class'
|
||
? apiSeatingLayout.saveClass(workspaceInfo.id, json)
|
||
: apiSeatingLayout.saveExam(workspaceInfo.id, json);
|
||
await saver;
|
||
localStorage.removeItem(legacyLayoutKey);
|
||
showSaveNotice('Đã lưu sơ đồ chỗ ngồi lên server');
|
||
} else {
|
||
const deleter = workspaceInfo.kind === 'class'
|
||
? apiSeatingLayout.deleteClass(workspaceInfo.id)
|
||
: apiSeatingLayout.deleteExam(workspaceInfo.id);
|
||
await deleter;
|
||
localStorage.removeItem(legacyLayoutKey);
|
||
showSaveNotice('Đã xóa sơ đồ chỗ ngồi trên server');
|
||
}
|
||
} catch {
|
||
if (newLayout) {
|
||
localStorage.setItem(legacyLayoutKey, JSON.stringify(newLayout));
|
||
}
|
||
showSaveNotice('Lưu server thất bại — đã giữ bản cục bộ tạm. Kiểm tra server đã deploy API sơ đồ chưa.', 'error');
|
||
} finally {
|
||
pendingLayoutRef.current = undefined;
|
||
}
|
||
};
|
||
|
||
const studentSeats = useMemo(() => {
|
||
if (!layout) return {} as Record<number, string>;
|
||
const mapping: Record<number, string> = {};
|
||
Object.entries(layout.seats).forEach(([cellKey, rkId]) => {
|
||
const [rStr, cStr] = cellKey.split(',');
|
||
const r = Number(rStr) + 1;
|
||
const c = Number(cStr) + 1;
|
||
mapping[rkId] = `Hàng ${r}, Cột ${c}`;
|
||
});
|
||
return mapping;
|
||
}, [layout]);
|
||
|
||
const filteredListStudents = useMemo(() => {
|
||
return students.filter(
|
||
(s) =>
|
||
s.fullName.toLowerCase().includes(listSearchQuery.toLowerCase()) ||
|
||
s.studentCode.toLowerCase().includes(listSearchQuery.toLowerCase())
|
||
);
|
||
}, [students, listSearchQuery]);
|
||
|
||
const hasPaperColumn = useMemo(() => {
|
||
return students.some((s) => s.paperTitle);
|
||
}, [students]);
|
||
|
||
useEffect(() => {
|
||
const storedTemplates = localStorage.getItem('sc_seating_templates');
|
||
if (storedTemplates) {
|
||
try { setTemplates(JSON.parse(storedTemplates)); } catch { setTemplates([]); }
|
||
}
|
||
if (workspaceInfo !== null) {
|
||
const getter = workspaceInfo.kind === 'class'
|
||
? apiSeatingLayout.getClass(workspaceInfo.id)
|
||
: apiSeatingLayout.getExam(workspaceInfo.id);
|
||
getter.then(async (json) => {
|
||
if (json) {
|
||
try { setLayout(JSON.parse(json)); localStorage.removeItem(legacyLayoutKey); } catch { setLayout(null); }
|
||
return;
|
||
}
|
||
const legacy = localStorage.getItem(legacyLayoutKey);
|
||
if (!legacy) { setLayout(null); return; }
|
||
try {
|
||
const parsed = JSON.parse(legacy) as SeatingLayout;
|
||
setLayout(parsed);
|
||
await persistLayout(parsed);
|
||
} catch { setLayout(null); }
|
||
}).catch(() => setLayout(null));
|
||
} else {
|
||
setLayout(null);
|
||
}
|
||
}, [workspaceId, workspaceInfo, legacyLayoutKey]);
|
||
|
||
const saveLayout = (newLayout: SeatingLayout | null) => {
|
||
setLayout(newLayout);
|
||
if (!workspaceInfo) return;
|
||
pendingLayoutRef.current = newLayout;
|
||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = setTimeout(() => {
|
||
void persistLayout(newLayout);
|
||
}, 600);
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (saveTimerRef.current) {
|
||
clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = null;
|
||
}
|
||
if (pendingLayoutRef.current !== undefined && workspaceInfo) {
|
||
void persistLayout(pendingLayoutRef.current);
|
||
}
|
||
};
|
||
}, [workspaceInfo, legacyLayoutKey]);
|
||
|
||
useEffect(() => {
|
||
if (saveNotice) {
|
||
const timer = setTimeout(() => setSaveNotice(null), 2500);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [saveNotice]);
|
||
|
||
const seatedStudentIds = useMemo(() => {
|
||
if (!layout) return new Set<number>();
|
||
return new Set<number>(Object.values(layout.seats));
|
||
}, [layout]);
|
||
|
||
const unseatedStudents = useMemo(() => {
|
||
return students.filter((s) => !seatedStudentIds.has(s.rkId));
|
||
}, [students, seatedStudentIds]);
|
||
|
||
const filteredUnseatedStudents = useMemo(() => {
|
||
return unseatedStudents.filter(
|
||
(s) =>
|
||
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase())
|
||
);
|
||
}, [unseatedStudents, searchQuery]);
|
||
|
||
const handleApplyTemplate = () => {
|
||
const tpl = templates.find((t) => t.id === selectedTemplateId);
|
||
if (!tpl) { alert('Vui lòng chọn sơ đồ mẫu.'); return; }
|
||
const newLayout: SeatingLayout = {
|
||
rows: tpl.rows,
|
||
cols: tpl.cols,
|
||
boardPosition: tpl.boardPosition,
|
||
lockedSeats: [...tpl.lockedSeats],
|
||
seats: {},
|
||
};
|
||
saveLayout(newLayout);
|
||
};
|
||
|
||
const handleApplyCustom = () => {
|
||
const newLayout: SeatingLayout = {
|
||
rows: customRows,
|
||
cols: customCols,
|
||
boardPosition: customBoard,
|
||
lockedSeats: [],
|
||
seats: {},
|
||
};
|
||
saveLayout(newLayout);
|
||
};
|
||
|
||
const handleDragStart = (e: React.DragEvent, source: string) => {
|
||
e.dataTransfer.setData('text/plain', source);
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent, cellKey: string) => {
|
||
e.preventDefault();
|
||
setDragOverCell(cellKey);
|
||
};
|
||
|
||
const handleDragLeave = () => setDragOverCell(null);
|
||
|
||
const handleDrop = (e: React.DragEvent, targetRow: number, targetCol: number) => {
|
||
e.preventDefault();
|
||
setDragOverCell(null);
|
||
if (!layout) return;
|
||
const source = e.dataTransfer.getData('text/plain');
|
||
if (!source) return;
|
||
const targetKey = `${targetRow},${targetCol}`;
|
||
if (layout.lockedSeats.includes(targetKey)) return;
|
||
const newSeats = { ...layout.seats };
|
||
const targetStudentId = newSeats[targetKey];
|
||
|
||
if (source.startsWith('unseated:')) {
|
||
const studentId = Number(source.replace('unseated:', ''));
|
||
newSeats[targetKey] = studentId;
|
||
Object.keys(newSeats).forEach((key) => {
|
||
if (key !== targetKey && newSeats[key] === studentId) delete newSeats[key];
|
||
});
|
||
} else if (source.startsWith('cell:')) {
|
||
const sourceKey = source.replace('cell:', '');
|
||
const studentId = newSeats[sourceKey];
|
||
if (!studentId) return;
|
||
if (targetStudentId) {
|
||
newSeats[targetKey] = studentId;
|
||
newSeats[sourceKey] = targetStudentId;
|
||
} else {
|
||
newSeats[targetKey] = studentId;
|
||
delete newSeats[sourceKey];
|
||
}
|
||
}
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
const handleUnseat = (cellKey: string) => {
|
||
if (!layout) return;
|
||
const newSeats = { ...layout.seats };
|
||
delete newSeats[cellKey];
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
const handleAutoAssign = () => {
|
||
if (!layout) return;
|
||
const emptyCells: string[] = [];
|
||
for (let r = 0; r < layout.rows; r++) {
|
||
for (let c = 0; c < layout.cols; c++) {
|
||
const key = `${r},${c}`;
|
||
if (!layout.lockedSeats.includes(key) && !layout.seats[key]) emptyCells.push(key);
|
||
}
|
||
}
|
||
if (unseatedStudents.length > emptyCells.length) {
|
||
alert(`Không đủ chỗ ngồi cho tất cả học viên! Số học viên chưa có chỗ: ${unseatedStudents.length}, Số chỗ trống khả dụng: ${emptyCells.length}. Thiếu ${unseatedStudents.length - emptyCells.length} chỗ.`);
|
||
}
|
||
const shuffledStudents = [...unseatedStudents].sort(() => Math.random() - 0.5);
|
||
const newSeats = { ...layout.seats };
|
||
const assignCount = Math.min(shuffledStudents.length, emptyCells.length);
|
||
for (let i = 0; i < assignCount; i++) {
|
||
newSeats[emptyCells[i]] = shuffledStudents[i].rkId;
|
||
}
|
||
saveLayout({ ...layout, seats: newSeats });
|
||
};
|
||
|
||
const handleApplyResize = () => {
|
||
if (!layout) return;
|
||
const newRows = Math.max(1, Math.min(editRows, 15));
|
||
const newCols = Math.max(1, Math.min(editCols, 20));
|
||
const newSeats: Record<string, number> = {};
|
||
const newLocked: string[] = [];
|
||
Object.entries(layout.seats).forEach(([key, rkId]) => {
|
||
const [r, c] = key.split(',').map(Number);
|
||
if (r < newRows && c < newCols) newSeats[key] = rkId;
|
||
});
|
||
layout.lockedSeats.forEach((key) => {
|
||
const [r, c] = key.split(',').map(Number);
|
||
if (r < newRows && c < newCols) newLocked.push(key);
|
||
});
|
||
saveLayout({
|
||
rows: newRows,
|
||
cols: newCols,
|
||
boardPosition: editBoardPos,
|
||
lockedSeats: newLocked,
|
||
seats: newSeats,
|
||
});
|
||
};
|
||
|
||
const handleClearAllSeats = () => {
|
||
if (!layout) return;
|
||
if (confirm('Bạn có chắc chắn muốn giải tán tất cả chỗ ngồi?')) {
|
||
saveLayout({ ...layout, seats: {} });
|
||
}
|
||
};
|
||
|
||
const handleResetLayout = () => {
|
||
if (confirm('Lưu ý: Xóa sơ đồ sẽ xóa toàn bộ vị trí chỗ ngồi hiện tại. Bạn có chắc chắn muốn thực hiện?')) {
|
||
saveLayout(null);
|
||
setIsEditingStructure(false);
|
||
}
|
||
};
|
||
|
||
const handleToggleCellLock = (r: number, c: number) => {
|
||
if (!layout) return;
|
||
const key = `${r},${c}`;
|
||
let newLocked = [...layout.lockedSeats];
|
||
let newSeats = { ...layout.seats };
|
||
if (newLocked.includes(key)) {
|
||
newLocked = newLocked.filter((k) => k !== key);
|
||
} else {
|
||
newLocked.push(key);
|
||
delete newSeats[key];
|
||
}
|
||
saveLayout({ ...layout, lockedSeats: newLocked, seats: newSeats });
|
||
};
|
||
|
||
// ── Board label ──────────────────────────────────────────────────────────────
|
||
|
||
const BoardLabel = ({ vertical = false }: { vertical?: boolean }) => (
|
||
<div className={`wsc-board-label${vertical ? ' wsc-board-label--vert' : ''}`}>
|
||
<IconBoard size={13} />
|
||
<span>BẢNG GIẢNG ĐƯỜNG</span>
|
||
</div>
|
||
);
|
||
|
||
// ── Seating grid ─────────────────────────────────────────────────────────────
|
||
|
||
const renderSeatingGrid = () => {
|
||
if (!layout) return null;
|
||
const gridItems = [];
|
||
for (let r = 0; r < layout.rows; r++) {
|
||
for (let c = 0; c < layout.cols; c++) {
|
||
const key = `${r},${c}`;
|
||
const isLocked = layout.lockedSeats.includes(key);
|
||
const studentId = layout.seats[key];
|
||
const student = studentId ? students.find((s) => s.rkId === studentId) : null;
|
||
const isOnline = student ? onlineIds.includes(student.rkId) : false;
|
||
const isHovered = dragOverCell === key;
|
||
|
||
let cellCls = 'wsc-grid-cell';
|
||
if (isLocked) cellCls += ' wsc-cell--locked';
|
||
else if (isHovered) cellCls += ' wsc-cell--dragover';
|
||
else if (student) cellCls += isOnline ? ' wsc-cell--online' : ' wsc-cell--occupied';
|
||
if (isEditingStructure) cellCls += ' wsc-cell--editing';
|
||
|
||
gridItems.push(
|
||
<div
|
||
key={key}
|
||
onDragOver={(e) => !isLocked && handleDragOver(e, key)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => !isLocked && handleDrop(e, r, c)}
|
||
onClick={() => isEditingStructure && handleToggleCellLock(r, c)}
|
||
className={cellCls}
|
||
draggable={!!student && !isEditingStructure}
|
||
onDragStart={(e) => student && handleDragStart(e, `cell:${key}`)}
|
||
>
|
||
{isLocked ? (
|
||
<div className="wsc-cell-locked-inner">
|
||
<IconLock size={14} />
|
||
<span className="wsc-cell-locked-label">Khóa</span>
|
||
</div>
|
||
) : student ? (
|
||
<div
|
||
className="wsc-seat-card"
|
||
onClick={() => { if (!isEditingStructure && onStudentClick) onStudentClick(student.rkId); }}
|
||
>
|
||
{!isEditingStructure && (
|
||
<button
|
||
type="button"
|
||
className="wsc-unseat-btn"
|
||
onClick={(e) => { e.stopPropagation(); handleUnseat(key); }}
|
||
title="Bỏ chỗ ngồi"
|
||
>
|
||
<IconX size={9} />
|
||
</button>
|
||
)}
|
||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={40} />
|
||
<span className="wsc-seat-name">{student.fullName}</span>
|
||
<span className="wsc-seat-code">{student.studentCode}</span>
|
||
{!isEditingStructure && <StudentOnlineBadge isOnline={isOnline} size="sm" />}
|
||
{student.paperTitle && (
|
||
<span className="wsc-seat-paper">
|
||
<IconFileText size={10} />
|
||
{student.paperTitle}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="wsc-cell-empty">
|
||
<span className="wsc-cell-coord">H{r + 1}-C{c + 1}</span>
|
||
<span className="wsc-cell-empty-label">Trống</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="wsc-grid-wrap">
|
||
{layout.boardPosition === 'top' && <BoardLabel />}
|
||
<div className="wsc-grid-row-wrap">
|
||
{layout.boardPosition === 'left' && <BoardLabel vertical />}
|
||
<div
|
||
className="wsc-grid-board"
|
||
style={{ gridTemplateColumns: `repeat(${layout.cols}, minmax(100px, 1fr))` }}
|
||
>
|
||
{gridItems}
|
||
</div>
|
||
{layout.boardPosition === 'right' && <BoardLabel vertical />}
|
||
</div>
|
||
{layout.boardPosition === 'bottom' && <BoardLabel />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="wsc-root">
|
||
|
||
{/* ── Empty / Setup state ─────────────────────────────────────────────── */}
|
||
{!layout ? (
|
||
<div className="wsc-setup-state">
|
||
<div className="wsc-setup-icon-wrap">
|
||
<IconChair size={34} />
|
||
</div>
|
||
<h2 className="wsc-setup-heading">Chưa thiết lập sơ đồ chỗ ngồi</h2>
|
||
<p className="wsc-setup-desc">
|
||
Thiết lập nhanh từ mẫu lớp học có sẵn hoặc tạo tự do kích thước mong muốn để xếp vị trí cho sinh viên.
|
||
</p>
|
||
|
||
{/* Mode switcher */}
|
||
<div className="wsc-seg" role="group" aria-label="Kiểu thiết lập">
|
||
<button
|
||
type="button"
|
||
className={`wsc-seg-btn${setupMode === 'template' ? ' wsc-seg-btn--active' : ''}`}
|
||
onClick={() => setSetupMode('template')}
|
||
>
|
||
<IconTemplate size={13} />
|
||
<span>Từ sơ đồ mẫu</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`wsc-seg-btn${setupMode === 'custom' ? ' wsc-seg-btn--active' : ''}`}
|
||
onClick={() => setSetupMode('custom')}
|
||
>
|
||
<IconSliders size={13} />
|
||
<span>Tạo tự do</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Setup panels */}
|
||
<div className="wsc-setup-panels">
|
||
{setupMode === 'template' ? (
|
||
<div className="wsc-setup-panel">
|
||
<select
|
||
className="select-filter"
|
||
style={{ width: '100%' }}
|
||
value={selectedTemplateId}
|
||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||
>
|
||
<option value="">— Chọn sơ đồ mẫu —</option>
|
||
{templates.map((t) => (
|
||
<option key={t.id} value={t.id}>{t.name} ({t.rows}×{t.cols})</option>
|
||
))}
|
||
</select>
|
||
<button className="btn btn-primary btn-sm wsc-setup-apply-btn" onClick={handleApplyTemplate}>
|
||
<IconCheck size={13} />
|
||
Áp dụng mẫu
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="wsc-setup-panel">
|
||
<div className="wsc-custom-fields">
|
||
<div className="wsc-field-group">
|
||
<label className="wsc-field-label">Hàng</label>
|
||
<input
|
||
type="number"
|
||
min={1} max={10}
|
||
className="search-input"
|
||
style={{ width: '70px' }}
|
||
value={customRows}
|
||
onChange={(e) => setCustomRows(Math.max(1, Number(e.target.value)))}
|
||
/>
|
||
</div>
|
||
<div className="wsc-field-group">
|
||
<label className="wsc-field-label">Cột</label>
|
||
<input
|
||
type="number"
|
||
min={1} max={15}
|
||
className="search-input"
|
||
style={{ width: '70px' }}
|
||
value={customCols}
|
||
onChange={(e) => setCustomCols(Math.max(1, Number(e.target.value)))}
|
||
/>
|
||
</div>
|
||
<div className="wsc-field-group">
|
||
<label className="wsc-field-label">Vị trí bảng</label>
|
||
<select
|
||
className="select-filter"
|
||
value={customBoard}
|
||
onChange={(e) => setCustomBoard(e.target.value as 'top' | 'bottom' | 'left' | 'right')}
|
||
>
|
||
<option value="top">Trên (Top)</option>
|
||
<option value="bottom">Dưới (Bottom)</option>
|
||
<option value="left">Trái (Left)</option>
|
||
<option value="right">Phải (Right)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-primary btn-sm wsc-setup-apply-btn" onClick={handleApplyCustom}>
|
||
<IconCheck size={13} />
|
||
Tạo sơ đồ trống
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
|
||
/* ── Active workspace ──────────────────────────────────────────────── */
|
||
<div className={`wsc-workspace-grid${viewMode === 'list' ? ' wsc-workspace-grid--list' : ''}`}>
|
||
|
||
{/* Left: main content */}
|
||
<div className="wsc-workspace-main">
|
||
|
||
{/* Toolbar */}
|
||
<div className="wsc-toolbar">
|
||
<div className="wsc-toolbar-left">
|
||
{/* View-mode segmented control */}
|
||
<div className="wsc-seg" role="group" aria-label="Chế độ xem">
|
||
<button
|
||
type="button"
|
||
className={`wsc-seg-btn${viewMode === 'grid' ? ' wsc-seg-btn--active' : ''}`}
|
||
onClick={() => setViewMode('grid')}
|
||
>
|
||
<IconGrid size={13} />
|
||
<span>Sơ đồ</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`wsc-seg-btn${viewMode === 'list' ? ' wsc-seg-btn--active' : ''}`}
|
||
onClick={() => setViewMode('list')}
|
||
>
|
||
<IconList size={13} />
|
||
<span>Danh sách</span>
|
||
</button>
|
||
</div>
|
||
|
||
{viewMode === 'grid' && (
|
||
<>
|
||
<button className="btn btn-primary btn-sm wsc-toolbar-btn" onClick={handleAutoAssign}>
|
||
<IconZap size={12} />
|
||
Xếp ngẫu nhiên
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm wsc-toolbar-btn" onClick={handleClearAllSeats}>
|
||
<IconTrash size={12} />
|
||
Giải tán
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{viewMode === 'grid' && (
|
||
<div className="wsc-toolbar-right">
|
||
<button
|
||
className={`btn btn-sm wsc-toolbar-btn${isEditingStructure ? ' btn-primary' : ' btn-secondary'}`}
|
||
onClick={() => {
|
||
if (!isEditingStructure && layout) {
|
||
setEditRows(layout.rows);
|
||
setEditCols(layout.cols);
|
||
setEditBoardPos(layout.boardPosition);
|
||
}
|
||
setIsEditingStructure(!isEditingStructure);
|
||
}}
|
||
>
|
||
{isEditingStructure ? <IconCheck size={12} /> : <IconSettings size={12} />}
|
||
{isEditingStructure ? 'Xong' : 'Sửa sơ đồ'}
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary btn-sm wsc-toolbar-btn wsc-reset-btn"
|
||
onClick={handleResetLayout}
|
||
>
|
||
<IconRefresh size={12} />
|
||
Reset
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit-structure banner */}
|
||
{viewMode === 'grid' && isEditingStructure && (
|
||
<div className="wsc-edit-banner">
|
||
<span className="wsc-edit-banner-hint">
|
||
<IconSettings size={13} />
|
||
Chế độ chỉnh sửa: Click vào ô để khóa / mở vị trí. Chỉnh kích thước bên dưới.
|
||
</span>
|
||
<div className="wsc-edit-fields">
|
||
<label className="wsc-field-label">Hàng</label>
|
||
<input type="number" min={1} max={15} value={editRows} onChange={(e) => setEditRows(Math.max(1, Math.min(15, Number(e.target.value))))} className="search-input wsc-edit-num" />
|
||
<label className="wsc-field-label">Cột</label>
|
||
<input type="number" min={1} max={20} value={editCols} onChange={(e) => setEditCols(Math.max(1, Math.min(20, Number(e.target.value))))} className="search-input wsc-edit-num" />
|
||
<label className="wsc-field-label">Bảng</label>
|
||
<select value={editBoardPos} onChange={(e) => setEditBoardPos(e.target.value as 'top' | 'bottom' | 'left' | 'right')} className="select-filter wsc-edit-select">
|
||
<option value="top">Top</option>
|
||
<option value="bottom">Bottom</option>
|
||
<option value="left">Left</option>
|
||
<option value="right">Right</option>
|
||
</select>
|
||
<button className="btn btn-primary btn-sm" onClick={handleApplyResize}>
|
||
<IconCheck size={12} />
|
||
Áp dụng
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Grid or list content */}
|
||
{viewMode === 'grid' ? (
|
||
<div className="wsc-grid-scroll">{renderSeatingGrid()}</div>
|
||
) : (
|
||
<div className="wsc-list-wrap">
|
||
<div className="wsc-list-search-wrap">
|
||
<IconSearch size={14} />
|
||
<input
|
||
type="search"
|
||
placeholder="Tìm sinh viên..."
|
||
className="search-input wsc-list-search"
|
||
value={listSearchQuery}
|
||
onChange={(e) => setListSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="attendance-table-scroll table-wrapper wsc-list-table-wrap">
|
||
{filteredListStudents.length === 0 ? (
|
||
<div className="wsc-list-empty">
|
||
<IconSearch size={28} />
|
||
<span>Không tìm thấy sinh viên nào.</span>
|
||
</div>
|
||
) : (
|
||
<table className="data-table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr>
|
||
<th className="wsc-th">Sinh viên</th>
|
||
<th className="wsc-th">Mã SV</th>
|
||
{hasPaperColumn && <th className="wsc-th">Gói đề</th>}
|
||
<th className="wsc-th">Vị trí</th>
|
||
<th className="wsc-th">Trạng thái</th>
|
||
<th className="wsc-th wsc-th--right">Thao tác</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredListStudents.map((s) => {
|
||
const isOnline = onlineIds.includes(s.rkId);
|
||
const seatLabel = studentSeats[s.rkId] || 'Chưa xếp chỗ';
|
||
return (
|
||
<tr key={s.rkId} className="wsc-list-row">
|
||
<td className="wsc-td">
|
||
<div className="wsc-td-student">
|
||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={28} />
|
||
<span className="wsc-td-name">{s.fullName}</span>
|
||
</div>
|
||
</td>
|
||
<td className="wsc-td"><code>{s.studentCode}</code></td>
|
||
{hasPaperColumn && <td className="wsc-td">{s.paperTitle || '—'}</td>}
|
||
<td className="wsc-td">
|
||
<span className={`badge ${studentSeats[s.rkId] ? 'badge-info' : 'badge-muted'}`} style={{ fontSize: '0.7rem' }}>{seatLabel}</span>
|
||
</td>
|
||
<td className="wsc-td"><StudentOnlineBadge isOnline={isOnline} size="md" /></td>
|
||
<td className="wsc-td wsc-td--right">
|
||
<button type="button" className="btn btn-primary btn-sm" style={{ padding: '0.2rem 0.6rem', fontSize: '0.72rem' }} onClick={() => onStudentClick && onStudentClick(s.rkId)}>Giám sát</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right: unseated sidebar */}
|
||
{viewMode === 'grid' && (
|
||
<div className="wsc-sidebar">
|
||
<div className="wsc-sidebar-header">
|
||
<span className="wsc-sidebar-title">Chưa vào chỗ</span>
|
||
<span className="wsc-unseated-badge">{unseatedStudents.length} SV</span>
|
||
</div>
|
||
<p className="wsc-sidebar-hint">Kéo thả vào vị trí trống trên sơ đồ.</p>
|
||
<div className="wsc-sidebar-search-wrap">
|
||
<IconSearch size={13} />
|
||
<input
|
||
type="search"
|
||
placeholder="Tìm tên, mã sinh viên..."
|
||
className="search-input wsc-sidebar-search"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="wsc-unseated-list">
|
||
{filteredUnseatedStudents.length === 0 ? (
|
||
<div className="wsc-unseated-empty">Không tìm thấy sinh viên.</div>
|
||
) : (
|
||
filteredUnseatedStudents.map((s) => {
|
||
const isOnline = onlineIds.includes(s.rkId);
|
||
return (
|
||
<div
|
||
key={s.rkId}
|
||
draggable
|
||
onDragStart={(e) => handleDragStart(e, `unseated:${s.rkId}`)}
|
||
onClick={() => onStudentClick && onStudentClick(s.rkId)}
|
||
className="wsc-unseated-card"
|
||
>
|
||
<StudentAvatar fullName={s.fullName} avatar={s.avatar} isOnline={isOnline} size={32} />
|
||
<div className="wsc-unseated-info">
|
||
<span className="wsc-unseated-name">{s.fullName}</span>
|
||
<span className="wsc-unseated-code">
|
||
{s.studentCode}{s.paperTitle && <> · <IconFileText size={10} /> {s.paperTitle}</>}
|
||
</span>
|
||
<StudentOnlineBadge isOnline={isOnline} size="sm" />
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Save toast ──────────────────────────────────────────────────────── */}
|
||
{saveNotice && (
|
||
<div className={`wsc-toast${saveNoticeKind === 'error' ? ' wsc-toast--error' : ' wsc-toast--success'}`}>
|
||
{saveNoticeKind === 'error'
|
||
? <IconX size={14} />
|
||
: <IconCheck size={14} />}
|
||
<span>{saveNotice}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Scoped styles ───────────────────────────────────────────────────── */}
|
||
<style>{`
|
||
/* ── Root layout ─────────────────────────────────────────── */
|
||
.wsc-root {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Setup / empty state ─────────────────────────────────── */
|
||
.wsc-setup-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
padding: 2rem 1.25rem 2.25rem;
|
||
}
|
||
.wsc-setup-icon-wrap {
|
||
color: var(--text-muted, #9ca3af);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 64px;
|
||
height: 64px;
|
||
background: var(--bg-subtle, #f0f3f7);
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
border-radius: 14px;
|
||
margin-bottom: 0.15rem;
|
||
}
|
||
.wsc-setup-heading {
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
}
|
||
.wsc-setup-desc {
|
||
color: var(--text-secondary);
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
max-width: 420px;
|
||
margin: 0 0 0.3rem;
|
||
line-height: 1.5;
|
||
}
|
||
.wsc-setup-panels {
|
||
width: 100%;
|
||
max-width: 380px;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.wsc-setup-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
padding: 1rem;
|
||
background: var(--bg-card, #fff);
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
border-radius: var(--radius-md, 10px);
|
||
}
|
||
.wsc-setup-apply-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
align-self: flex-end;
|
||
}
|
||
.wsc-custom-fields {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem 1rem;
|
||
align-items: flex-end;
|
||
}
|
||
.wsc-field-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
.wsc-field-label {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted, #9ca3af);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
/* ── Segmented controls ──────────────────────────────────── */
|
||
.wsc-seg {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 3px;
|
||
background: var(--bg-subtle, #f0f3f7);
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
border-radius: 9px;
|
||
flex-shrink: 0;
|
||
}
|
||
.wsc-seg-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.3rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary, #6b7280);
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
padding: 0.3rem 0.7rem;
|
||
line-height: 1;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.wsc-seg-btn svg { flex-shrink: 0; opacity: 0.85; }
|
||
.wsc-seg-btn:hover:not(.wsc-seg-btn--active) {
|
||
color: var(--text-primary, #1a2332);
|
||
background: rgba(255,255,255,0.65);
|
||
}
|
||
.wsc-seg-btn--active {
|
||
background: #fff;
|
||
color: var(--accent, #bb2126);
|
||
box-shadow: 0 1px 2px rgba(26,35,50,0.08), 0 0 0 1px rgba(187,33,38,0.12);
|
||
}
|
||
.wsc-seg-btn--active svg { opacity: 1; }
|
||
|
||
/* ── Workspace layout ────────────────────────────────────── */
|
||
.wsc-workspace-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 268px;
|
||
gap: 1rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
.wsc-workspace-grid--list {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.wsc-workspace-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Toolbar ─────────────────────────────────────────────── */
|
||
.wsc-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.wsc-toolbar-left,
|
||
.wsc-toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.wsc-toolbar-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0.28rem 0.65rem !important;
|
||
font-size: 0.75rem !important;
|
||
}
|
||
.wsc-reset-btn {
|
||
border-color: #fca5a5 !important;
|
||
color: var(--danger, #dc2626) !important;
|
||
}
|
||
.wsc-reset-btn:hover {
|
||
background: #fef2f2 !important;
|
||
}
|
||
|
||
/* ── Edit-structure banner ───────────────────────────────── */
|
||
.wsc-edit-banner {
|
||
background: #fffbeb;
|
||
border: 1px solid #fde68a;
|
||
padding: 0.55rem 0.9rem;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.45rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.wsc-edit-banner-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: #92400e;
|
||
font-size: 0.77rem;
|
||
font-weight: 600;
|
||
}
|
||
.wsc-edit-fields {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.wsc-edit-num {
|
||
width: 56px !important;
|
||
padding: 0.2rem 0.4rem !important;
|
||
font-size: 0.75rem !important;
|
||
}
|
||
.wsc-edit-select {
|
||
padding: 0.2rem 0.4rem !important;
|
||
font-size: 0.75rem !important;
|
||
}
|
||
.wsc-edit-fields .wsc-field-label { color: #92400e; }
|
||
|
||
/* ── Grid scroll area ────────────────────────────────────── */
|
||
.wsc-grid-scroll {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
.wsc-grid-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.wsc-grid-row-wrap {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0.65rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.wsc-grid-board {
|
||
display: grid;
|
||
gap: 8px;
|
||
flex: 1;
|
||
padding: 10px;
|
||
background: var(--bg-app, #f5f7fa);
|
||
border-radius: var(--radius-md, 10px);
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
align-content: start;
|
||
overflow: auto;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* ── Board label ─────────────────────────────────────────── */
|
||
.wsc-board-label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
background: #374151;
|
||
color: #fff;
|
||
padding: 0.32rem 0.7rem;
|
||
border-radius: 6px;
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
align-self: center;
|
||
width: 55%;
|
||
}
|
||
.wsc-board-label--vert {
|
||
writing-mode: vertical-lr;
|
||
text-orientation: mixed;
|
||
width: auto;
|
||
padding: 0.6rem 0.35rem;
|
||
align-self: stretch;
|
||
height: auto;
|
||
}
|
||
.wsc-board-label--vert span { margin-top: 4px; }
|
||
|
||
/* ── Grid cells ──────────────────────────────────────────── */
|
||
.wsc-grid-cell {
|
||
min-height: 118px;
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 0.5rem 0.4rem;
|
||
position: relative;
|
||
transition: var(--transition, all 0.15s);
|
||
background: #f9fafb;
|
||
cursor: default;
|
||
}
|
||
.wsc-grid-cell.wsc-cell--editing {
|
||
border: 1.5px dashed var(--border-color, #e4e9f0) !important;
|
||
cursor: pointer;
|
||
}
|
||
.wsc-grid-cell.wsc-cell--locked {
|
||
background: #e5e7eb;
|
||
cursor: default;
|
||
}
|
||
.wsc-grid-cell.wsc-cell--dragover {
|
||
border-color: var(--accent, #bb2126) !important;
|
||
background: #fff0f0 !important;
|
||
box-shadow: 0 0 0 2px rgba(187,33,38,0.15);
|
||
}
|
||
.wsc-grid-cell.wsc-cell--online {
|
||
background: linear-gradient(180deg,#ecfdf5 0%,#ffffff 55%);
|
||
cursor: grab;
|
||
}
|
||
.wsc-grid-cell.wsc-cell--occupied {
|
||
background: linear-gradient(180deg,#f8fafc 0%,#ffffff 55%);
|
||
cursor: grab;
|
||
}
|
||
|
||
/* ── Locked cell ─────────────────────────────────────────── */
|
||
.wsc-cell-locked-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: #9ca3af;
|
||
}
|
||
.wsc-cell-locked-label {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
/* ── Occupied seat card ──────────────────────────────────── */
|
||
.wsc-seat-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
width: 100%;
|
||
gap: 0.18rem;
|
||
}
|
||
.wsc-seat-card[style*="pointer"],
|
||
.wsc-seat-card { cursor: default; }
|
||
.wsc-unseat-btn {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
border: none;
|
||
background: #fee2e2;
|
||
color: var(--danger, #dc2626);
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
font-weight: 700;
|
||
padding: 0;
|
||
transition: background 0.15s;
|
||
}
|
||
.wsc-unseat-btn:hover { background: #fecaca; }
|
||
.wsc-seat-name {
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
margin-top: 2px;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
word-break: break-word;
|
||
width: 100%;
|
||
line-height: 1.25;
|
||
max-height: 3.75em;
|
||
}
|
||
.wsc-seat-code {
|
||
font-size: 0.67rem;
|
||
color: var(--text-secondary);
|
||
font-family: monospace;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
width: 100%;
|
||
}
|
||
.wsc-seat-paper {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
font-size: 0.64rem;
|
||
background: var(--accent-light, #fef2f2);
|
||
color: var(--accent, #bb2126);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
margin-top: 1px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* ── Empty cell ──────────────────────────────────────────── */
|
||
.wsc-cell-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
opacity: 0.38;
|
||
gap: 2px;
|
||
}
|
||
.wsc-cell-coord {
|
||
font-size: 0.73rem;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
.wsc-cell-empty-label {
|
||
font-size: 0.67rem;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* ── List view ───────────────────────────────────────────── */
|
||
.wsc-list-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
gap: 8px;
|
||
}
|
||
.wsc-list-search-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--text-muted);
|
||
max-width: 320px;
|
||
}
|
||
.wsc-list-search {
|
||
flex: 1;
|
||
padding: 0.38rem 0.6rem !important;
|
||
font-size: 0.84rem !important;
|
||
}
|
||
.wsc-list-table-wrap {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-sm, 6px);
|
||
}
|
||
.wsc-list-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 2.5rem 1rem;
|
||
color: var(--text-muted);
|
||
font-size: 0.85rem;
|
||
}
|
||
.wsc-th {
|
||
padding: 0.5rem 0.8rem;
|
||
background: var(--bg-subtle);
|
||
font-weight: 700;
|
||
font-size: 0.72rem;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
text-align: left;
|
||
}
|
||
.wsc-th--right { text-align: right; }
|
||
.wsc-td {
|
||
padding: 0.5rem 0.8rem;
|
||
font-size: 0.82rem;
|
||
color: var(--text-primary);
|
||
}
|
||
.wsc-td--right { text-align: right; }
|
||
.wsc-list-row { border-bottom: 1px solid var(--border-light, #f0f3f7); }
|
||
.wsc-td-student {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.wsc-td-name { font-weight: 600; }
|
||
|
||
/* ── Sidebar ─────────────────────────────────────────────── */
|
||
.wsc-sidebar {
|
||
border-left: 1px solid var(--border-color, #e4e9f0);
|
||
padding-left: 1.25rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
overflow: hidden;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.wsc-sidebar-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.wsc-sidebar-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
.wsc-unseated-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
background: var(--accent-light, #fef2f2);
|
||
color: var(--accent, #bb2126);
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
padding: 0.15rem 0.45rem;
|
||
border-radius: 10px;
|
||
}
|
||
.wsc-sidebar-hint {
|
||
margin: 0;
|
||
color: var(--text-secondary);
|
||
font-size: 0.77rem;
|
||
margin-top: -0.2rem;
|
||
}
|
||
.wsc-sidebar-search-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
color: var(--text-muted);
|
||
}
|
||
.wsc-sidebar-search {
|
||
flex: 1;
|
||
padding: 0.38rem 0.55rem !important;
|
||
font-size: 0.78rem !important;
|
||
}
|
||
.wsc-unseated-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
padding-right: 3px;
|
||
min-height: 160px;
|
||
}
|
||
.wsc-unseated-empty {
|
||
text-align: center;
|
||
padding: 1.5rem 0;
|
||
color: var(--text-muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
.wsc-unseated-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
padding: 0.45rem 0.55rem;
|
||
border: 1px solid var(--border-color, #e4e9f0);
|
||
border-radius: 9px;
|
||
background: #fff;
|
||
cursor: grab;
|
||
transition: var(--transition, all 0.15s);
|
||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.06));
|
||
}
|
||
.wsc-unseated-card:hover {
|
||
border-color: var(--accent, #bb2126);
|
||
box-shadow: 0 2px 8px rgba(187,33,38,0.1);
|
||
}
|
||
.wsc-unseated-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
flex: 1;
|
||
min-width: 0;
|
||
gap: 1px;
|
||
}
|
||
.wsc-unseated-name {
|
||
font-size: 0.79rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.wsc-unseated-code {
|
||
font-size: 0.69rem;
|
||
color: var(--text-secondary);
|
||
font-family: monospace;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
}
|
||
|
||
/* ── Save toast ──────────────────────────────────────────── */
|
||
.wsc-toast {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
padding: 0.6rem 1.1rem;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-lg, 0 4px 24px rgba(0,0,0,0.14));
|
||
font-size: 0.84rem;
|
||
font-weight: 600;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
max-width: min(420px, calc(100vw - 48px));
|
||
color: #fff;
|
||
animation: wsc-toast-in 0.2s ease;
|
||
}
|
||
.wsc-toast--success { background: #10b981; }
|
||
.wsc-toast--error { background: #ef4444; }
|
||
@keyframes wsc-toast-in {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
};
|