feat: add monthly history controls for organization view
This commit is contained in:
@@ -11,6 +11,9 @@ const currentViewTitle = document.getElementById("current-view-title");
|
|||||||
const globalDateControls = document.getElementById("global-date-controls");
|
const globalDateControls = document.getElementById("global-date-controls");
|
||||||
const globalStartDateInput = document.getElementById("global-start-date");
|
const globalStartDateInput = document.getElementById("global-start-date");
|
||||||
const globalEndDateInput = document.getElementById("global-end-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 navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
|
||||||
const organizationFrame = document.getElementById("organization-frame");
|
const organizationFrame = document.getElementById("organization-frame");
|
||||||
const organizationStage = document.getElementById("organization-stage");
|
const organizationStage = document.getElementById("organization-stage");
|
||||||
@@ -155,6 +158,89 @@ const globalDateState = {
|
|||||||
endDate: "2026-01-31",
|
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() {
|
function getGlobalAsOfDate() {
|
||||||
return globalDateState.endDate || "";
|
return globalDateState.endDate || "";
|
||||||
}
|
}
|
||||||
@@ -188,7 +274,6 @@ function shouldShowGlobalDateControls() {
|
|||||||
return currentView === "ledger"
|
return currentView === "ledger"
|
||||||
|| currentView === "project"
|
|| currentView === "project"
|
||||||
|| currentView === "team"
|
|| currentView === "team"
|
||||||
|| currentView === "organization"
|
|
||||||
|| currentView === "seatmap-admin"
|
|| currentView === "seatmap-admin"
|
||||||
|| currentView === "seatmap-readonly";
|
|| currentView === "seatmap-readonly";
|
||||||
}
|
}
|
||||||
@@ -196,6 +281,7 @@ function shouldShowGlobalDateControls() {
|
|||||||
function syncGlobalDateControlVisibility() {
|
function syncGlobalDateControlVisibility() {
|
||||||
if (!globalDateControls) return;
|
if (!globalDateControls) return;
|
||||||
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
|
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
|
||||||
|
syncOrganizationHistoryControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncGlobalDateControlInputs() {
|
function syncGlobalDateControlInputs() {
|
||||||
@@ -244,7 +330,6 @@ async function ensureGlobalDateRangeLoaded() {
|
|||||||
globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : "";
|
globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : "";
|
||||||
globalDateState.loaded = true;
|
globalDateState.loaded = true;
|
||||||
syncGlobalDateControlInputs();
|
syncGlobalDateControlInputs();
|
||||||
postGlobalDateRangeToFrame(organizationFrame);
|
|
||||||
postGlobalDateRangeToFrame(projectFrame);
|
postGlobalDateRangeToFrame(projectFrame);
|
||||||
postGlobalDateRangeToFrame(teamFrame);
|
postGlobalDateRangeToFrame(teamFrame);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1446,6 +1531,8 @@ function setActiveView(view) {
|
|||||||
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
if (isOrganization && previousView !== "organization" && organizationFrame) {
|
||||||
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
|
||||||
organizationFrame.src = resolveAppUrl(frameSrc);
|
organizationFrame.src = resolveAppUrl(frameSrc);
|
||||||
|
} else if (isOrganization) {
|
||||||
|
postOrganizationHistoryState();
|
||||||
}
|
}
|
||||||
if (isProject && previousView !== "project" && projectFrame) {
|
if (isProject && previousView !== "project" && projectFrame) {
|
||||||
const frameSrc = projectFrame.dataset.src || projectFrame.src;
|
const frameSrc = projectFrame.dataset.src || projectFrame.src;
|
||||||
@@ -1547,7 +1634,6 @@ if (logoutBtn) {
|
|||||||
if (globalStartDateInput) {
|
if (globalStartDateInput) {
|
||||||
globalStartDateInput.addEventListener("change", () => {
|
globalStartDateInput.addEventListener("change", () => {
|
||||||
globalDateState.startDate = globalStartDateInput.value || "";
|
globalDateState.startDate = globalStartDateInput.value || "";
|
||||||
postGlobalDateRangeToFrame(organizationFrame);
|
|
||||||
postGlobalDateRangeToFrame(projectFrame);
|
postGlobalDateRangeToFrame(projectFrame);
|
||||||
postGlobalDateRangeToFrame(teamFrame);
|
postGlobalDateRangeToFrame(teamFrame);
|
||||||
});
|
});
|
||||||
@@ -1556,7 +1642,6 @@ if (globalStartDateInput) {
|
|||||||
if (globalEndDateInput) {
|
if (globalEndDateInput) {
|
||||||
globalEndDateInput.addEventListener("change", () => {
|
globalEndDateInput.addEventListener("change", () => {
|
||||||
globalDateState.endDate = globalEndDateInput.value || "";
|
globalDateState.endDate = globalEndDateInput.value || "";
|
||||||
postGlobalDateRangeToFrame(organizationFrame);
|
|
||||||
postGlobalDateRangeToFrame(projectFrame);
|
postGlobalDateRangeToFrame(projectFrame);
|
||||||
postGlobalDateRangeToFrame(teamFrame);
|
postGlobalDateRangeToFrame(teamFrame);
|
||||||
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
|
if (currentView === "seatmap-admin" || currentView === "seatmap-readonly") {
|
||||||
@@ -1567,7 +1652,7 @@ if (globalEndDateInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
organizationFrame?.addEventListener("load", () => {
|
organizationFrame?.addEventListener("load", () => {
|
||||||
postGlobalDateRangeToFrame(organizationFrame);
|
postOrganizationHistoryState();
|
||||||
});
|
});
|
||||||
|
|
||||||
projectFrame?.addEventListener("load", () => {
|
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) => {
|
Object.values(seatMapDom).forEach((dom) => {
|
||||||
dom.officeTabs?.addEventListener("click", (event) => {
|
dom.officeTabs?.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("[data-seatmap-office]");
|
const button = event.target.closest("[data-seatmap-office]");
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||||
<link rel="stylesheet" href="/styles.css?v=20260326-01">
|
<link rel="stylesheet" href="/styles.css?v=20260330-01">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section id="login-panel" class="login-screen">
|
<section id="login-panel" class="login-screen">
|
||||||
@@ -58,6 +58,14 @@
|
|||||||
<input id="global-end-date" type="date" aria-label="종료일">
|
<input id="global-end-date" type="date" aria-label="종료일">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="organization-history-controls" class="header-date-controls hidden">
|
||||||
|
<span class="header-date-label">조직 기준월</span>
|
||||||
|
<label class="header-date-field">
|
||||||
|
<select id="organization-month-select" aria-label="조직 기준월"></select>
|
||||||
|
</label>
|
||||||
|
<button id="organization-compare-btn" class="ghost-button ghost-button-soft hidden" type="button">조직도 변경사항 확인</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -85,7 +93,7 @@
|
|||||||
<main class="dashboard-main">
|
<main class="dashboard-main">
|
||||||
<section id="organization-stage" class="main-stage">
|
<section id="organization-stage" class="main-stage">
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
<iframe id="organization-frame" src="/legacy/organization?v=20260326-02" data-src="/legacy/organization?v=20260326-02" title="조직도 메인 화면"></iframe>
|
<iframe id="organization-frame" src="/legacy/organization?v=20260330-01" data-src="/legacy/organization?v=20260330-01" title="조직도 메인 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="project-stage" class="main-stage" hidden>
|
<section id="project-stage" class="main-stage" hidden>
|
||||||
@@ -205,6 +213,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="/app.js?v=20260326-02"></script>
|
<script src="/app.js?v=20260330-01"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ body {
|
|||||||
outline: none;
|
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 {
|
.header-date-sep {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의
|
|||||||
let photoPreviewObjectUrl = null;
|
let photoPreviewObjectUrl = null;
|
||||||
let seatMapLayoutCache = null;
|
let seatMapLayoutCache = null;
|
||||||
let activeAsOfDate = '';
|
let activeAsOfDate = '';
|
||||||
|
let isHistoricalSnapshot = false;
|
||||||
const listViewState = {
|
const listViewState = {
|
||||||
mode: 'current',
|
mode: 'current',
|
||||||
snapshotDate: '',
|
snapshotDate: '',
|
||||||
@@ -165,7 +166,7 @@ async function loadMembers(message) {
|
|||||||
if (message) {
|
if (message) {
|
||||||
emptyStateMessage = message;
|
emptyStateMessage = message;
|
||||||
}
|
}
|
||||||
const payload = await apiFetch('/api/members');
|
const payload = await apiFetch(withAsOf('/api/members'));
|
||||||
setMembers(payload.items || []);
|
setMembers(payload.items || []);
|
||||||
if (!members.length) {
|
if (!members.length) {
|
||||||
emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
||||||
@@ -185,7 +186,7 @@ async function loadSeatMapLayouts(force = false) {
|
|||||||
if (!seatMap?.id) {
|
if (!seatMap?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await apiFetch(`/api/seat-maps/${seatMap.id}/layout`);
|
return await apiFetch(withAsOf(`/api/seat-maps/${seatMap.id}/layout`));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -643,6 +644,10 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAdminMode(checked) {
|
function toggleAdminMode(checked) {
|
||||||
|
if (checked && isHistoricalSnapshot) {
|
||||||
|
alert('월말 히스토리 조회 중에는 수정할 수 없습니다. 최신 월로 돌아간 뒤 수정해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
isAdmin = checked;
|
isAdmin = checked;
|
||||||
const button = document.getElementById('admin-mode-btn');
|
const button = document.getElementById('admin-mode-btn');
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -670,7 +675,7 @@ function updateFabMenu() {
|
|||||||
let html = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
|
let html = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="자리배치도" onclick="openSeatMapView(event)">🪑</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="자리배치도" onclick="openSeatMapView(event)">🪑</button>';
|
||||||
if (isAdmin) {
|
if (isAdmin && !isHistoricalSnapshot) {
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
|
||||||
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
|
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
|
||||||
@@ -678,6 +683,19 @@ function updateFabMenu() {
|
|||||||
menu.innerHTML = html;
|
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) {
|
function openSeatMapView(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
document.getElementById('fab-container').classList.remove('active');
|
document.getElementById('fab-container').classList.remove('active');
|
||||||
@@ -695,6 +713,25 @@ window.addEventListener('message', (event) => {
|
|||||||
activeAsOfDate = String(data.endDate || '').slice(0, 10);
|
activeAsOfDate = String(data.endDate || '').slice(0, 10);
|
||||||
return;
|
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') {
|
if (data.type === 'seatmap-layout-updated') {
|
||||||
handleSeatMapLayoutUpdated();
|
handleSeatMapLayoutUpdated();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user