This commit is contained in:
@@ -1223,13 +1223,15 @@ export const apiSeatingLayout = {
|
|||||||
return json.layoutJson ?? null;
|
return json.layoutJson ?? null;
|
||||||
},
|
},
|
||||||
saveClass: async (classRkId: number, layoutJson: string): Promise<void> => {
|
saveClass: async (classRkId: number, layoutJson: string): Promise<void> => {
|
||||||
await staffFetch(`/classes/${classRkId}/seating-layout`, {
|
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ layoutJson }),
|
body: JSON.stringify({ layoutJson }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Lưu sơ đồ lớp thất bại');
|
||||||
},
|
},
|
||||||
deleteClass: async (classRkId: number): Promise<void> => {
|
deleteClass: async (classRkId: number): Promise<void> => {
|
||||||
await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' });
|
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa sơ đồ lớp thất bại');
|
||||||
},
|
},
|
||||||
getExam: async (examRoomId: number): Promise<string | null> => {
|
getExam: async (examRoomId: number): Promise<string | null> => {
|
||||||
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`);
|
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`);
|
||||||
@@ -1238,13 +1240,15 @@ export const apiSeatingLayout = {
|
|||||||
return json.layoutJson ?? null;
|
return json.layoutJson ?? null;
|
||||||
},
|
},
|
||||||
saveExam: async (examRoomId: number, layoutJson: string): Promise<void> => {
|
saveExam: async (examRoomId: number, layoutJson: string): Promise<void> => {
|
||||||
await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, {
|
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ layoutJson }),
|
body: JSON.stringify({ layoutJson }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Lưu sơ đồ phòng thi thất bại');
|
||||||
},
|
},
|
||||||
deleteExam: async (examRoomId: number): Promise<void> => {
|
deleteExam: async (examRoomId: number): Promise<void> => {
|
||||||
await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' });
|
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa sơ đồ phòng thi thất bại');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
const [editBoardPos, setEditBoardPos] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
const [editBoardPos, setEditBoardPos] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
||||||
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
const [dragOverCell, setDragOverCell] = useState<string | null>(null);
|
||||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||||
|
const [saveNoticeKind, setSaveNoticeKind] = useState<'success' | 'error'>('success');
|
||||||
|
|
||||||
// Toggle layout view modes
|
// Toggle layout view modes
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
@@ -67,6 +68,42 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
}, [workspaceId]);
|
}, [workspaceId]);
|
||||||
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reverse map seat positions for list view
|
// Reverse map seat positions for list view
|
||||||
const studentSeats = useMemo(() => {
|
const studentSeats = useMemo(() => {
|
||||||
@@ -111,42 +148,60 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
const getter = workspaceInfo.kind === 'class'
|
const getter = workspaceInfo.kind === 'class'
|
||||||
? apiSeatingLayout.getClass(workspaceInfo.id)
|
? apiSeatingLayout.getClass(workspaceInfo.id)
|
||||||
: apiSeatingLayout.getExam(workspaceInfo.id);
|
: apiSeatingLayout.getExam(workspaceInfo.id);
|
||||||
getter.then((json) => {
|
getter.then(async (json) => {
|
||||||
if (json) {
|
if (json) {
|
||||||
try { setLayout(JSON.parse(json)); } catch { setLayout(null); }
|
try {
|
||||||
} else {
|
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);
|
setLayout(null);
|
||||||
}
|
}
|
||||||
}).catch(() => setLayout(null));
|
}).catch(() => setLayout(null));
|
||||||
} else {
|
} else {
|
||||||
setLayout(null);
|
setLayout(null);
|
||||||
}
|
}
|
||||||
}, [workspaceId, workspaceInfo]);
|
}, [workspaceId, workspaceInfo, legacyLayoutKey]);
|
||||||
|
|
||||||
// Save layout — debounce để tránh gọi API quá nhiều khi kéo thả nhanh
|
// Save layout — debounce để tránh gọi API quá nhiều khi kéo thả nhanh
|
||||||
const saveLayout = (newLayout: SeatingLayout | null) => {
|
const saveLayout = (newLayout: SeatingLayout | null) => {
|
||||||
setLayout(newLayout);
|
setLayout(newLayout);
|
||||||
if (!workspaceInfo) return;
|
if (!workspaceInfo) return;
|
||||||
|
|
||||||
|
pendingLayoutRef.current = newLayout;
|
||||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
if (newLayout) {
|
void persistLayout(newLayout);
|
||||||
const json = JSON.stringify(newLayout);
|
|
||||||
const saver = workspaceInfo.kind === 'class'
|
|
||||||
? apiSeatingLayout.saveClass(workspaceInfo.id, json)
|
|
||||||
: apiSeatingLayout.saveExam(workspaceInfo.id, json);
|
|
||||||
saver.catch(() => {});
|
|
||||||
setSaveNotice('Đã lưu sơ đồ chỗ ngồi!');
|
|
||||||
} else {
|
|
||||||
const deleter = workspaceInfo.kind === 'class'
|
|
||||||
? apiSeatingLayout.deleteClass(workspaceInfo.id)
|
|
||||||
: apiSeatingLayout.deleteExam(workspaceInfo.id);
|
|
||||||
deleter.catch(() => {});
|
|
||||||
setSaveNotice('Đã xóa sơ đồ chỗ ngồi!');
|
|
||||||
}
|
|
||||||
}, 600);
|
}, 600);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pendingLayoutRef.current !== undefined && workspaceInfo) {
|
||||||
|
void persistLayout(pendingLayoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [workspaceInfo, legacyLayoutKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveNotice) {
|
if (saveNotice) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -856,7 +911,7 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: '24px',
|
bottom: '24px',
|
||||||
right: '24px',
|
right: '24px',
|
||||||
background: '#10b981',
|
background: saveNoticeKind === 'error' ? '#ef4444' : '#10b981',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
padding: '0.65rem 1.25rem',
|
padding: '0.65rem 1.25rem',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
@@ -867,8 +922,9 @@ export const WorkspaceSeatingChart: React.FC<WorkspaceSeatingChartProps> = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
|
maxWidth: 'min(420px, calc(100vw - 48px))',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: '1.1rem' }}>✓</span> {saveNotice}
|
<span style={{ fontSize: '1.1rem' }}>{saveNoticeKind === 'error' ? '!' : '✓'}</span> {saveNotice}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user