feat: promote seatmap and organization updates

This commit is contained in:
hyunho
2026-03-30 16:40:07 +09:00
parent 2e8c79bb43
commit f77be3f482
5 changed files with 250 additions and 71 deletions

View File

@@ -982,7 +982,7 @@ def parse_fixed_office_template(office_key: str = FIXED_OFFICE_SOURCE_KEY) -> di
raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}") raise HTTPException(status_code=500, detail=f"Fixed office viewer data not found: {office_key}")
html = re.sub( html = re.sub(
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>', r'<script\s+src="\./[^"]+payload[^"]*\.js(?:\?[^"]*)?"></script>',
f"<script>{payload_js}</script>", f"<script>{payload_js}</script>",
html, html,
count=1, count=1,
@@ -1582,6 +1582,10 @@ def fetch_seat_layout(seat_map_id: int, as_of: datetime | None = None) -> dict[s
(as_of, as_of, seat_map_id, as_of, as_of), (as_of, as_of, seat_map_id, as_of, as_of),
) )
placements = cur.fetchall() placements = cur.fetchall()
cur.execute("SELECT name FROM member_retirements")
retired_names = {str(row["name"] or "").strip() for row in cur.fetchall() if str(row["name"] or "").strip()}
for member in members:
member["is_retired"] = str(member.get("name") or "").strip() in retired_names
viewer_data: dict[str, object] | None = None viewer_data: dict[str, object] | None = None
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY) office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_KEY)
fixed_office = FIXED_OFFICE_CONFIGS.get(office_key) fixed_office = FIXED_OFFICE_CONFIGS.get(office_key)
@@ -1807,6 +1811,8 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
<script> <script>
const seatAssignments = new Map(); const seatAssignments = new Map();
let selectedChairKey = null; let selectedChairKey = null;
let focusedChairKey = null;
let focusedChairPulseUntil = 0;
let viewerMode = "default"; let viewerMode = "default";
const popup = document.createElement("div"); const popup = document.createElement("div");
popup.className = "seat-popup"; popup.className = "seat-popup";
@@ -1854,6 +1860,9 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
function focusChair(chairKey, padding = 2200) { function focusChair(chairKey, padding = 2200) {
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey)); const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
if (!chair) return; if (!chair) return;
selectedChairKey = String(chairKey);
focusedChairKey = String(chairKey);
focusedChairPulseUntil = Date.now() + 2600;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const pad = 24; const pad = 24;
const minX = chair.minX - padding; const minX = chair.minX - padding;
@@ -1919,8 +1928,31 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
const originalDraw = draw; const originalDraw = draw;
draw = function drawWithAssignments() { draw = function drawWithAssignments() {
originalDraw(); originalDraw();
if (!seatAssignments.size) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const now = Date.now();
const focusedChair = focusedChairKey
? chairGeometry.find((item) => String(item.key) === String(focusedChairKey))
: null;
if (focusedChair && now <= focusedChairPulseUntil) {
const pulse = (Math.sin(now / 180) + 1) / 2;
const center = worldToScreen((focusedChair.minX + focusedChair.maxX) / 2, (focusedChair.minY + focusedChair.maxY) / 2);
const width = Math.max(34, (focusedChair.maxX - focusedChair.minX) * camera.scale + 20 + pulse * 18);
const height = Math.max(34, (focusedChair.maxY - focusedChair.minY) * camera.scale + 20 + pulse * 18);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.save();
ctx.strokeStyle = `rgba(245, 158, 11, ${0.38 + pulse * 0.32})`;
ctx.lineWidth = 3 + pulse * 2;
ctx.shadowColor = "rgba(245, 158, 11, 0.35)";
ctx.shadowBlur = 16 + pulse * 10;
ctx.beginPath();
ctx.roundRect(center.x - width / 2, center.y - height / 2, width, height, 16);
ctx.stroke();
ctx.restore();
if (typeof requestDraw === "function") requestDraw();
} else if (focusedChair && now > focusedChairPulseUntil) {
focusedChairPulseUntil = 0;
}
if (!seatAssignments.size) return;
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
for (const chair of chairGeometry) { for (const chair of chairGeometry) {

View File

@@ -325,9 +325,7 @@ function buildAuthHeaders(headers) {
function shouldShowGlobalDateControls() { function shouldShowGlobalDateControls() {
return currentView === "ledger" return currentView === "ledger"
|| currentView === "project" || currentView === "project"
|| currentView === "team" || currentView === "team";
|| currentView === "seatmap-admin"
|| currentView === "seatmap-readonly";
} }
function syncGlobalDateControlVisibility() { function syncGlobalDateControlVisibility() {
@@ -361,6 +359,10 @@ function buildAsOfQuery() {
return `?as_of=${encodeURIComponent(asOf)}`; return `?as_of=${encodeURIComponent(asOf)}`;
} }
function buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() { function notifyEmbeddedTabActivated() {
if (currentView === "project" && projectFrame?.contentWindow) { if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin); projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
@@ -787,6 +789,22 @@ function getPlacementForMember(memberId) {
return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null; return getPlacementSource().find((item) => Number(item.member_id) === Number(memberId)) || null;
} }
function isMemberAssignedAnywhere(member) {
const seatLabel = String(
member?.member_seat_label
|| member?.seat_label
|| ""
).trim();
return Boolean(seatLabel);
}
function shouldHideMemberFromSeatMap(member) {
if (Boolean(member?.is_retired)) return true;
const workStatus = String(member?.work_status || "").trim();
if (/(퇴사|퇴직)/u.test(workStatus)) return true;
return false;
}
function memberMatchesSeatMapSearch(member) { function memberMatchesSeatMapSearch(member) {
const keyword = seatMapState.search.trim().toLowerCase(); const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true; if (!keyword) return true;
@@ -839,7 +857,9 @@ function renderSeatMapOfficeTabs() {
function getUnassignedMembers() { function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id))); const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => { return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false; if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member); return memberMatchesSeatMapSearch(member);
}); });
} }
@@ -847,6 +867,7 @@ function getUnassignedMembers() {
function getPlacedMembers() { function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id))); const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => { return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (!placedIds.has(Number(member.id))) return false; if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member); return memberMatchesSeatMapSearch(member);
}); });
@@ -1011,7 +1032,7 @@ function renderDxfSeatMapBoard() {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`; seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
return; return;
} }
const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildAsOfQuery()}`); const viewerUrl = resolveAppUrl(`/api/seat-maps/${seatMapState.seatMap.id}/viewer${buildSeatMapAsOfQuery()}`);
seatMapBoard.innerHTML = ` seatMapBoard.innerHTML = `
<div class="seatmap-dxf-frame-shell"> <div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div> <div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
@@ -1354,7 +1375,7 @@ async function loadSeatMapData(force = false) {
const office = getCurrentSeatMapOffice(); const office = getCurrentSeatMapOffice();
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`); const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
const activeSeatMap = activePayload.item; const activeSeatMap = activePayload.item;
const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildAsOfQuery()}`); const layoutPayload = await fetchJson(`/api/seat-maps/${activeSeatMap.id}/layout${buildSeatMapAsOfQuery()}`);
seatMapState.seatMap = { seatMapState.seatMap = {
...(layoutPayload.seat_map || {}), ...(layoutPayload.seat_map || {}),
viewer_data: layoutPayload.viewer_data || null, viewer_data: layoutPayload.viewer_data || null,

File diff suppressed because one or more lines are too long

View File

@@ -316,7 +316,28 @@ body {
} }
.modal-content.wide { .modal-content.wide {
max-width: 1200px; max-width: 1060px;
width: min(1040px, calc(100vw - 48px));
max-height: calc(100vh - 28px);
padding: 20px 20px 16px;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.modal-content.wide #modal-title {
margin-bottom: 0;
padding-bottom: 12px;
}
.modal-content.wide #modal-fields {
min-height: 0;
}
.modal-content.wide #modal-footer-area {
margin-top: 0 !important;
flex-shrink: 0;
} }
.member-photo-field { .member-photo-field {
@@ -333,37 +354,59 @@ body {
align-items: stretch; align-items: stretch;
} }
.member-edit-layout { .member-basic-editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.member-basic-split {
display: grid; display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px; gap: 14px;
align-items: stretch; align-items: stretch;
} }
.member-edit-left-pane, .member-basic-left,
.member-edit-right-pane { .member-basic-right,
.member-photo-panel,
.member-basic-fields {
min-width: 0; min-width: 0;
} }
.member-edit-left-pane { .member-basic-left,
display: flex; .member-basic-right,
flex-direction: column; .member-photo-panel {
height: 100%;
}
.member-basic-left {
display: grid;
grid-template-rows: auto 1fr;
gap: 12px; gap: 12px;
} }
.member-edit-profile-card { .member-basic-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
align-content: start;
}
.member-basic-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 5px;
min-width: 0;
} }
.member-seat-field { .member-seat-field {
flex: 1 1 auto;
min-height: 0; min-height: 0;
} }
.member-seat-field-emphasis .seat-preview-card { .member-seat-field-compact .seat-preview-card {
min-height: 100%; min-height: 100%;
height: 100%;
} }
.member-detail-top-row { .member-detail-top-row {
@@ -455,8 +498,6 @@ body {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 16px; border-radius: 16px;
background: #f8fafc; background: #f8fafc;
height: 100%;
min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -467,13 +508,21 @@ body {
justify-content: space-between; justify-content: space-between;
} }
.member-photo-upload-card-inline {
min-height: 168px;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.member-photo-preview-wrap { .member-photo-preview-wrap {
flex: 0 0 auto; flex: 0 0 auto;
} }
.member-photo-preview { .member-photo-preview {
width: 84px; width: 96px;
height: 84px; height: 96px;
border-radius: 9999px; border-radius: 9999px;
object-fit: cover; object-fit: cover;
border: 3px solid #e0e7ff; border: 3px solid #e0e7ff;
@@ -520,6 +569,11 @@ body {
min-height: 100%; min-height: 100%;
} }
.seat-preview-card.is-assigned {
border-color: rgba(79, 70, 229, 0.28);
box-shadow: 0 14px 30px rgba(79, 70, 229, 0.12);
}
.seat-preview-head { .seat-preview-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -549,10 +603,11 @@ body {
align-items: center; align-items: center;
padding: 6px 10px; padding: 6px 10px;
border-radius: 9999px; border-radius: 9999px;
background: #dbeafe; background: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
color: #1d4ed8; color: #ffffff;
font-size: 11px; font-size: 11px;
font-weight: 900; font-weight: 900;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.18);
} }
.seat-preview-badge-muted { .seat-preview-badge-muted {
@@ -583,6 +638,11 @@ body {
overflow: hidden; overflow: hidden;
} }
.seat-preview-card.is-assigned .seat-preview-canvas {
border-color: rgba(79, 70, 229, 0.35);
box-shadow: inset 0 0 0 1px rgba(79, 70, 229, 0.08);
}
.seat-preview-frame { .seat-preview-frame {
display: block; display: block;
width: 100%; width: 100%;
@@ -591,6 +651,10 @@ body {
background: #fff; background: #fff;
} }
.seat-preview-card.is-assigned .seat-preview-frame {
box-shadow: inset 0 0 0 2px rgba(79, 70, 229, 0.14);
}
.seat-preview-placeholder { .seat-preview-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -601,30 +665,78 @@ body {
font-weight: 900; font-weight: 900;
} }
.member-edit-right-pane .seat-preview-head { .member-seat-field-compact .seat-preview-head {
padding: 18px 20px 12px; padding: 14px 16px 10px;
} }
.member-edit-right-pane .seat-preview-head strong { .member-seat-field-compact .seat-preview-head strong {
font-size: 18px; font-size: 17px;
} }
.member-edit-right-pane .seat-preview-head p { .member-seat-field-compact .seat-preview-head p {
font-size: 12px; font-size: 12px;
line-height: 1.35;
} }
.member-edit-right-pane .seat-preview-badge { .member-seat-field-compact .seat-preview-badge {
font-size: 12px; font-size: 11px;
padding: 8px 12px; padding: 7px 10px;
} }
.member-edit-right-pane .seat-preview-canvas { .member-seat-field-compact .seat-preview-canvas {
min-height: 360px; min-height: 208px;
} }
.member-edit-right-pane .seat-preview-frame, .member-seat-field-compact .seat-preview-frame,
.member-edit-right-pane .seat-preview-placeholder { .member-seat-field-compact .seat-preview-placeholder {
min-height: 320px; min-height: 208px;
}
.member-photo-upload-card-inline {
min-height: 168px;
padding: 16px 14px;
gap: 10px;
align-items: stretch;
justify-content: space-between;
text-align: left;
}
.member-photo-card-title {
font-size: 11px;
font-weight: 900;
color: #475569;
line-height: 1.2;
}
.member-photo-upload-card-inline .member-photo-preview-wrap {
margin: 0 auto;
}
.member-photo-upload-card-inline .member-photo-preview {
width: 82px;
height: 82px;
}
.member-photo-upload-card-inline .member-photo-upload-controls {
align-items: center;
text-align: center;
}
.member-photo-upload-card-inline .member-photo-file-label {
padding: 9px 12px;
}
.member-photo-upload-card-inline .member-photo-file-name {
font-size: 11px;
}
.member-basic-field input {
min-width: 0;
padding: 12px 14px;
}
.member-basic-field label {
line-height: 1.2;
} }
@@ -645,7 +757,11 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.member-edit-layout { .member-basic-split {
grid-template-columns: 1fr;
}
.member-basic-fields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -44,6 +44,15 @@ function cloneMembers(items) {
return JSON.parse(JSON.stringify(items)); return JSON.parse(JSON.stringify(items));
} }
function isRetiredLegacyMember(member) {
const workStatus = String(member?.['근무상태'] || '').trim();
return workStatus === '퇴직';
}
function getVisibleLegacyMembers(items) {
return (items || []).filter((member) => !isRetiredLegacyMember(member));
}
function getPhotoPlaceholder(name = '') { function getPhotoPlaceholder(name = '') {
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`; return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
} }
@@ -155,7 +164,7 @@ async function uploadProfilePhoto(file, memberName) {
} }
function setMembers(items) { function setMembers(items) {
members = items.map(toLegacyMember); members = getVisibleLegacyMembers(items.map(toLegacyMember));
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) { if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
selectedDept = '전체'; selectedDept = '전체';
} }
@@ -1014,11 +1023,11 @@ function handlePhotoFileChange(event) {
function renderSeatPreviewCard(seatInfo) { function renderSeatPreviewCard(seatInfo) {
const assigned = Boolean(seatInfo?.assigned); const assigned = Boolean(seatInfo?.assigned);
const safeLabel = escapeHtml(seatInfo?.seatLabel || ''); const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실';
const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도'); const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
const safeSlotKey = escapeHtml(seatInfo?.slotKey || ''); const safeOfficeLabel = escapeHtml(seatMapLabel);
const badge = assigned const badge = assigned
? `<span class="seat-preview-badge">${safeLabel || '배치완료'}</span>` ? `<span class="seat-preview-badge">${safeOfficeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>'; : '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
const body = assigned const body = assigned
? ` ? `
@@ -1039,11 +1048,11 @@ function renderSeatPreviewCard(seatInfo) {
`; `;
return ` return `
<div class="seat-preview-card"> <div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
<div class="seat-preview-head"> <div class="seat-preview-head">
<div> <div>
<strong>재석위치</strong> <strong>재석위치</strong>
<p>${assigned ? '현재 자리배치도 기준으로 배치된 좌석 정보를 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p> <p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
</div> </div>
${badge} ${badge}
</div> </div>
@@ -1177,8 +1186,9 @@ function openModal(id) {
<div class="col-span-1"> <div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label> <label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"> <select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="근무" ${member['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option> <option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option> <option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select> </select>
</div> </div>
<div class="col-span-1"> <div class="col-span-1">
@@ -1199,15 +1209,15 @@ function openModal(id) {
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button> <button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button> <button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
</div> </div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-3 modal-form-grid"> <div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}"> <input type="hidden" id="m-id" value="${id || ''}">
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}"> <input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}"> <input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="col-span-2 member-edit-layout"> <div class="member-basic-split">
<div class="member-edit-left-pane"> <div class="member-basic-left">
<div class="member-edit-profile-card"> <div class="member-photo-panel">
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label> <div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-upload-card member-photo-upload-card-compact"> <div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap"> <div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview"> <img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div> </div>
@@ -1219,28 +1229,28 @@ function openModal(id) {
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong> <strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div> </div>
</div> </div>
<div class="member-name-field member-name-field-compact"> </div>
<div class="member-basic-fields">
<div class="member-basic-field">
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label> <label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"> <input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
</div> </div>
<div class="member-inline-info-grid member-inline-info-grid-stacked"> <div class="member-basic-field">
<div class="member-inline-info-card member-inline-info-card-full"> <label class="text-[11px] font-black text-slate-600 block">사번</label>
<label>사번</label> <input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> </div>
</div> <div class="member-basic-field">
<div class="member-inline-info-card member-inline-info-card-full"> <label class="text-[11px] font-black text-slate-600 block">전화번호</label>
<label>전화번호</label> <input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"> </div>
</div> <div class="member-basic-field">
<div class="member-inline-info-card member-inline-info-card-full"> <label class="text-[11px] font-black text-slate-600 block">이메일</label>
<label>이메일</label> <input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
<input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="member-edit-right-pane"> <div class="member-basic-right">
<div class="member-seat-field member-seat-field-emphasis"> <div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div> <div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div> </div>
</div> </div>
@@ -1583,7 +1593,7 @@ async function loadSnapshotListView() {
} }
const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`); const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`);
listViewState.snapshotDate = snapshotDate; listViewState.snapshotDate = snapshotDate;
listViewState.snapshotMembers = (payload.items || []).map(toLegacyMember); listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember));
listViewState.mode = 'snapshot'; listViewState.mode = 'snapshot';
renderListViewModalContent(); renderListViewModalContent();
} }