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}")
html = re.sub(
r'<script\s+src="\./[^"]+payload[^"]*\.js"></script>',
r'<script\s+src="\./[^"]+payload[^"]*\.js(?:\?[^"]*)?"></script>',
f"<script>{payload_js}</script>",
html,
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),
)
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
office_key = str(seat_map.get("source_url") or FIXED_OFFICE_SOURCE_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>
const seatAssignments = new Map();
let selectedChairKey = null;
let focusedChairKey = null;
let focusedChairPulseUntil = 0;
let viewerMode = "default";
const popup = document.createElement("div");
popup.className = "seat-popup";
@@ -1854,6 +1860,9 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
function focusChair(chairKey, padding = 2200) {
const chair = chairGeometry.find((item) => String(item.key) === String(chairKey));
if (!chair) return;
selectedChairKey = String(chairKey);
focusedChairKey = String(chairKey);
focusedChairPulseUntil = Date.now() + 2600;
const rect = canvas.getBoundingClientRect();
const pad = 24;
const minX = chair.minX - padding;
@@ -1919,8 +1928,31 @@ def build_center_chair_viewer_html(layout: dict[str, object]) -> str:
const originalDraw = draw;
draw = function drawWithAssignments() {
originalDraw();
if (!seatAssignments.size) return;
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.textBaseline = "middle";
for (const chair of chairGeometry) {

View File

@@ -325,9 +325,7 @@ function buildAuthHeaders(headers) {
function shouldShowGlobalDateControls() {
return currentView === "ledger"
|| currentView === "project"
|| currentView === "team"
|| currentView === "seatmap-admin"
|| currentView === "seatmap-readonly";
|| currentView === "team";
}
function syncGlobalDateControlVisibility() {
@@ -361,6 +359,10 @@ function buildAsOfQuery() {
return `?as_of=${encodeURIComponent(asOf)}`;
}
function buildSeatMapAsOfQuery() {
return "";
}
function notifyEmbeddedTabActivated() {
if (currentView === "project" && projectFrame?.contentWindow) {
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;
}
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) {
const keyword = seatMapState.search.trim().toLowerCase();
if (!keyword) return true;
@@ -839,7 +857,9 @@ function renderSeatMapOfficeTabs() {
function getUnassignedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (placedIds.has(Number(member.id))) return false;
if (isMemberAssignedAnywhere(member)) return false;
return memberMatchesSeatMapSearch(member);
});
}
@@ -847,6 +867,7 @@ function getUnassignedMembers() {
function getPlacedMembers() {
const placedIds = new Set(getPlacementSource().map((item) => Number(item.member_id)));
return seatMapState.members.filter((member) => {
if (shouldHideMemberFromSeatMap(member)) return false;
if (!placedIds.has(Number(member.id))) return false;
return memberMatchesSeatMapSearch(member);
});
@@ -1011,7 +1032,7 @@ function renderDxfSeatMapBoard() {
seatMapBoard.innerHTML = `<div class="seatmap-empty-card"><strong>DXF 뷰어 데이터를 준비하지 못했습니다.</strong></div>`;
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 = `
<div class="seatmap-dxf-frame-shell">
<div class="seatmap-dxf-drop-overlay" data-seatmap-drop-overlay></div>
@@ -1354,7 +1375,7 @@ async function loadSeatMapData(force = false) {
const office = getCurrentSeatMapOffice();
const activePayload = await fetchJson(`/api/seat-maps/active?office_key=${encodeURIComponent(office.key)}`);
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 = {
...(layoutPayload.seat_map || {}),
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 {
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();
}