Files
rikkei_simple_care/management/src/components/WorkspaceSeatingChart.tsx
Pham Hung 3b80550c76
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
tt (#1)
Reviewed-on: #1
Co-authored-by: Pham Hung <22.phamviethung.12a12@gmail.com>
Co-committed-by: Pham Hung <22.phamviethung.12a12@gmail.com>
2026-07-23 10:16:52 +07:00

1498 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 đ chỗ ngồi</h2>
<p className="wsc-setup-desc">
Thiết lập nhanh từ mẫu lớp họ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ừ đ 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 đ 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 đ 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> đ</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"> 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 đ.</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>
);
};