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

@@ -316,7 +316,28 @@ body {
}
.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 {
@@ -333,37 +354,59 @@ body {
align-items: stretch;
}
.member-edit-layout {
.member-basic-editor {
display: flex;
flex-direction: column;
gap: 14px;
}
.member-basic-split {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
align-items: stretch;
}
.member-edit-left-pane,
.member-edit-right-pane {
.member-basic-left,
.member-basic-right,
.member-photo-panel,
.member-basic-fields {
min-width: 0;
}
.member-edit-left-pane {
display: flex;
flex-direction: column;
.member-basic-left,
.member-basic-right,
.member-photo-panel {
height: 100%;
}
.member-basic-left {
display: grid;
grid-template-rows: auto 1fr;
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;
flex-direction: column;
gap: 12px;
gap: 5px;
min-width: 0;
}
.member-seat-field {
flex: 1 1 auto;
min-height: 0;
}
.member-seat-field-emphasis .seat-preview-card {
.member-seat-field-compact .seat-preview-card {
min-height: 100%;
height: 100%;
}
.member-detail-top-row {
@@ -455,8 +498,6 @@ body {
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
height: 100%;
min-height: 100%;
box-sizing: border-box;
}
@@ -467,13 +508,21 @@ body {
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 {
flex: 0 0 auto;
}
.member-photo-preview {
width: 84px;
height: 84px;
width: 96px;
height: 96px;
border-radius: 9999px;
object-fit: cover;
border: 3px solid #e0e7ff;
@@ -520,6 +569,11 @@ body {
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 {
display: flex;
justify-content: space-between;
@@ -549,10 +603,11 @@ body {
align-items: center;
padding: 6px 10px;
border-radius: 9999px;
background: #dbeafe;
color: #1d4ed8;
background: linear-gradient(135deg, #4f46e5 0%, #2563eb 100%);
color: #ffffff;
font-size: 11px;
font-weight: 900;
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.18);
}
.seat-preview-badge-muted {
@@ -583,6 +638,11 @@ body {
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 {
display: block;
width: 100%;
@@ -591,6 +651,10 @@ body {
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 {
display: flex;
flex-direction: column;
@@ -601,30 +665,78 @@ body {
font-weight: 900;
}
.member-edit-right-pane .seat-preview-head {
padding: 18px 20px 12px;
.member-seat-field-compact .seat-preview-head {
padding: 14px 16px 10px;
}
.member-edit-right-pane .seat-preview-head strong {
font-size: 18px;
.member-seat-field-compact .seat-preview-head strong {
font-size: 17px;
}
.member-edit-right-pane .seat-preview-head p {
.member-seat-field-compact .seat-preview-head p {
font-size: 12px;
line-height: 1.35;
}
.member-edit-right-pane .seat-preview-badge {
font-size: 12px;
padding: 8px 12px;
.member-seat-field-compact .seat-preview-badge {
font-size: 11px;
padding: 7px 10px;
}
.member-edit-right-pane .seat-preview-canvas {
min-height: 360px;
.member-seat-field-compact .seat-preview-canvas {
min-height: 208px;
}
.member-edit-right-pane .seat-preview-frame,
.member-edit-right-pane .seat-preview-placeholder {
min-height: 320px;
.member-seat-field-compact .seat-preview-frame,
.member-seat-field-compact .seat-preview-placeholder {
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;
}
.member-edit-layout {
.member-basic-split {
grid-template-columns: 1fr;
}
.member-basic-fields {
grid-template-columns: 1fr;
}

View File

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