From bbebe24763ff3208541272ec3f0968312bf031dd Mon Sep 17 00:00:00 2001 From: hyunho Date: Mon, 30 Mar 2026 10:08:00 +0900 Subject: [PATCH] feat: add monthly history controls for organization view --- frontend/public/app.js | 121 ++++++++++++++++++++++++++++++++-- frontend/public/index.html | 14 +++- frontend/public/styles.css | 11 ++++ legacy/static/organization.js | 43 +++++++++++- 4 files changed, 178 insertions(+), 11 deletions(-) diff --git a/frontend/public/app.js b/frontend/public/app.js index c2413b3..ccfde08 100644 --- a/frontend/public/app.js +++ b/frontend/public/app.js @@ -11,6 +11,9 @@ const currentViewTitle = document.getElementById("current-view-title"); const globalDateControls = document.getElementById("global-date-controls"); const globalStartDateInput = document.getElementById("global-start-date"); const globalEndDateInput = document.getElementById("global-end-date"); +const organizationHistoryControls = document.getElementById("organization-history-controls"); +const organizationMonthSelect = document.getElementById("organization-month-select"); +const organizationCompareBtn = document.getElementById("organization-compare-btn"); const navButtons = Array.from(document.querySelectorAll(".header-center [data-view]")); const organizationFrame = document.getElementById("organization-frame"); const organizationStage = document.getElementById("organization-stage"); @@ -155,6 +158,89 @@ const globalDateState = { endDate: "2026-01-31", }; +const organizationHistoryState = { + selectedMonth: "", + currentMonth: "", +}; + +function padDatePart(value) { + return String(value).padStart(2, "0"); +} + +function getCurrentMonthValue() { + const now = new Date(); + return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}`; +} + +function getMonthLabel(monthValue) { + const [, month] = String(monthValue || "").split("-"); + if (!month) return ""; + const monthNumber = Number(month); + if (!Number.isInteger(monthNumber) || monthNumber <= 0) return ""; + return `${monthNumber}월`; +} + +function getMonthEndDate(monthValue) { + const [yearText, monthText] = String(monthValue || "").split("-"); + const year = Number(yearText); + const month = Number(monthText); + if (!Number.isInteger(year) || !Number.isInteger(month) || month <= 0) return ""; + const lastDay = new Date(year, month, 0); + return `${lastDay.getFullYear()}-${padDatePart(lastDay.getMonth() + 1)}-${padDatePart(lastDay.getDate())}`; +} + +function getTodayDate() { + const now = new Date(); + return `${now.getFullYear()}-${padDatePart(now.getMonth() + 1)}-${padDatePart(now.getDate())}`; +} + +function syncOrganizationHistoryControls() { + if (!organizationHistoryControls) return; + const visible = currentView === "organization"; + organizationHistoryControls.classList.toggle("hidden", !visible); + if (organizationCompareBtn) { + const isCurrentMonth = !organizationHistoryState.selectedMonth || organizationHistoryState.selectedMonth === organizationHistoryState.currentMonth; + organizationCompareBtn.classList.toggle("hidden", !visible || isCurrentMonth); + } +} + +function initializeOrganizationMonthOptions() { + if (!organizationMonthSelect) return; + const now = new Date(); + const year = now.getFullYear(); + const monthCount = now.getMonth() + 1; + organizationMonthSelect.innerHTML = ""; + for (let month = monthCount; month >= 1; month -= 1) { + const value = `${year}-${padDatePart(month)}`; + const option = document.createElement("option"); + option.value = value; + option.textContent = month === monthCount ? `${month}월 (최신)` : `${month}월`; + organizationMonthSelect.append(option); + } + organizationHistoryState.currentMonth = `${year}-${padDatePart(monthCount)}`; + organizationHistoryState.selectedMonth = organizationHistoryState.currentMonth; + organizationMonthSelect.value = organizationHistoryState.selectedMonth; + syncOrganizationHistoryControls(); +} + +function postOrganizationHistoryState() { + if (!organizationFrame?.contentWindow) return; + const selectedMonth = organizationHistoryState.selectedMonth || organizationHistoryState.currentMonth; + const currentMonth = organizationHistoryState.currentMonth || getCurrentMonthValue(); + const isHistorical = Boolean(selectedMonth) && selectedMonth !== currentMonth; + organizationFrame.contentWindow.postMessage( + { + source: "total-control", + type: "organization-history-view", + month: selectedMonth, + asOfDate: isHistorical ? getMonthEndDate(selectedMonth) : "", + historical: isHistorical, + }, + window.location.origin, + ); + syncOrganizationHistoryControls(); +} + function getGlobalAsOfDate() { return globalDateState.endDate || ""; } @@ -188,7 +274,6 @@ function shouldShowGlobalDateControls() { return currentView === "ledger" || currentView === "project" || currentView === "team" - || currentView === "organization" || currentView === "seatmap-admin" || currentView === "seatmap-readonly"; } @@ -196,6 +281,7 @@ function shouldShowGlobalDateControls() { function syncGlobalDateControlVisibility() { if (!globalDateControls) return; globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls()); + syncOrganizationHistoryControls(); } function syncGlobalDateControlInputs() { @@ -244,7 +330,6 @@ async function ensureGlobalDateRangeLoaded() { globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : ""; globalDateState.loaded = true; syncGlobalDateControlInputs(); - postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); } catch (error) { @@ -1446,6 +1531,8 @@ function setActiveView(view) { if (isOrganization && previousView !== "organization" && organizationFrame) { const frameSrc = organizationFrame.dataset.src || organizationFrame.src; organizationFrame.src = resolveAppUrl(frameSrc); + } else if (isOrganization) { + postOrganizationHistoryState(); } if (isProject && previousView !== "project" && projectFrame) { const frameSrc = projectFrame.dataset.src || projectFrame.src; @@ -1547,7 +1634,6 @@ if (logoutBtn) { if (globalStartDateInput) { globalStartDateInput.addEventListener("change", () => { globalDateState.startDate = globalStartDateInput.value || ""; - postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); }); @@ -1556,7 +1642,6 @@ if (globalStartDateInput) { if (globalEndDateInput) { globalEndDateInput.addEventListener("change", () => { globalDateState.endDate = globalEndDateInput.value || ""; - postGlobalDateRangeToFrame(organizationFrame); postGlobalDateRangeToFrame(projectFrame); postGlobalDateRangeToFrame(teamFrame); if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") { @@ -1567,7 +1652,7 @@ if (globalEndDateInput) { } organizationFrame?.addEventListener("load", () => { - postGlobalDateRangeToFrame(organizationFrame); + postOrganizationHistoryState(); }); projectFrame?.addEventListener("load", () => { @@ -1591,6 +1676,32 @@ navButtons.forEach((button) => { }); }); +if (organizationMonthSelect) { + initializeOrganizationMonthOptions(); + organizationMonthSelect.addEventListener("change", () => { + organizationHistoryState.selectedMonth = organizationMonthSelect.value || organizationHistoryState.currentMonth; + postOrganizationHistoryState(); + }); +} + +if (organizationCompareBtn) { + organizationCompareBtn.addEventListener("click", () => { + if (!organizationFrame?.contentWindow) return; + const fromDate = getMonthEndDate(organizationHistoryState.selectedMonth); + const toDate = getTodayDate(); + if (!fromDate) return; + organizationFrame.contentWindow.postMessage( + { + source: "total-control", + type: "open-history-compare", + fromDate, + toDate, + }, + window.location.origin, + ); + }); +} + Object.values(seatMapDom).forEach((dom) => { dom.officeTabs?.addEventListener("click", (event) => { const button = event.target.closest("[data-seatmap-office]"); diff --git a/frontend/public/index.html b/frontend/public/index.html index 135b78e..f2bd8f4 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -13,7 +13,7 @@ - +
@@ -58,6 +58,14 @@ + +
@@ -85,7 +93,7 @@
- +
- + diff --git a/frontend/public/styles.css b/frontend/public/styles.css index 9c84542..d622efe 100644 --- a/frontend/public/styles.css +++ b/frontend/public/styles.css @@ -306,6 +306,17 @@ body { outline: none; } +.header-date-field select { + border: 0; + background: transparent; + color: var(--color-text); + font-size: 12px; + font-weight: 700; + outline: none; + appearance: none; + padding-right: 8px; +} + .header-date-sep { color: #94a3b8; font-size: 12px; diff --git a/legacy/static/organization.js b/legacy/static/organization.js index 35394e5..779581c 100644 --- a/legacy/static/organization.js +++ b/legacy/static/organization.js @@ -8,6 +8,7 @@ let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 let photoPreviewObjectUrl = null; let seatMapLayoutCache = null; let activeAsOfDate = ''; +let isHistoricalSnapshot = false; const listViewState = { mode: 'current', snapshotDate: '', @@ -165,7 +166,7 @@ async function loadMembers(message) { if (message) { emptyStateMessage = message; } - const payload = await apiFetch('/api/members'); + const payload = await apiFetch(withAsOf('/api/members')); setMembers(payload.items || []); if (!members.length) { emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; @@ -185,7 +186,7 @@ async function loadSeatMapLayouts(force = false) { if (!seatMap?.id) { return null; } - return await apiFetch(`/api/seat-maps/${seatMap.id}/layout`); + return await apiFetch(withAsOf(`/api/seat-maps/${seatMap.id}/layout`)); } catch { return null; } @@ -643,6 +644,10 @@ function render() { } function toggleAdminMode(checked) { + if (checked && isHistoricalSnapshot) { + alert('월말 히스토리 조회 중에는 수정할 수 없습니다. 최신 월로 돌아간 뒤 수정해주세요.'); + return; + } isAdmin = checked; const button = document.getElementById('admin-mode-btn'); if (isAdmin) { @@ -670,7 +675,7 @@ function updateFabMenu() { let html = ''; html += ''; html += ''; - if (isAdmin) { + if (isAdmin && !isHistoricalSnapshot) { html += ''; html += ''; html += ''; @@ -678,6 +683,19 @@ function updateFabMenu() { menu.innerHTML = html; } +async function openHistoryCompareModal(fromDate, toDate) { + openListViewModal(); + const fromInput = document.getElementById('list-compare-from'); + const toInput = document.getElementById('list-compare-to'); + if (fromInput) { + fromInput.value = fromDate || ''; + } + if (toInput) { + toInput.value = toDate || ''; + } + await loadCompareListView(); +} + function openSeatMapView(event) { event.stopPropagation(); document.getElementById('fab-container').classList.remove('active'); @@ -695,6 +713,25 @@ window.addEventListener('message', (event) => { activeAsOfDate = String(data.endDate || '').slice(0, 10); return; } + if (data.type === 'organization-history-view') { + activeAsOfDate = String(data.asOfDate || '').slice(0, 10); + isHistoricalSnapshot = Boolean(data.historical); + if (isHistoricalSnapshot && isAdmin) { + toggleAdminMode(false); + } else { + updateFabMenu(); + render(); + } + seatMapLayoutCache = null; + loadMembers().catch(() => { }); + return; + } + if (data.type === 'open-history-compare') { + openHistoryCompareModal(String(data.fromDate || ''), String(data.toDate || '')).catch((error) => { + alert(error.message || '변경 비교를 불러오지 못했습니다.'); + }); + return; + } if (data.type === 'seatmap-layout-updated') { handleSeatMapLayoutUpdated(); }