feat: integrate dashboard modules and document history

This commit is contained in:
hyunho
2026-03-26 18:03:07 +09:00
parent baf6019c1c
commit 61b5638cb1
22 changed files with 14252 additions and 79 deletions

View File

@@ -8,9 +8,16 @@ const logoutBtn = document.getElementById("logout-btn");
const userBadge = document.getElementById("user-badge");
const userPopover = document.getElementById("user-popover");
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 navButtons = Array.from(document.querySelectorAll(".header-center [data-view]"));
const organizationFrame = document.getElementById("organization-frame");
const organizationStage = document.getElementById("organization-stage");
const projectFrame = document.getElementById("project-frame");
const projectStage = document.getElementById("project-stage");
const teamFrame = document.getElementById("team-frame");
const teamStage = document.getElementById("team-stage");
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
const emptyStage = document.getElementById("empty-stage");
@@ -142,6 +149,11 @@ const seatMapState = {
};
let currentView = "organization";
const globalDateState = {
loaded: true,
startDate: "2026-01-01",
endDate: "2026-01-31",
};
function getSession() {
try {
@@ -159,6 +171,71 @@ function clearSession() {
sessionStorage.removeItem(sessionKey);
}
function buildAuthHeaders(headers) {
const nextHeaders = new Headers(headers || {});
const token = getSession()?.token;
if (token && !nextHeaders.has("Authorization")) {
nextHeaders.set("Authorization", `Bearer ${token}`);
}
return nextHeaders;
}
function shouldShowGlobalDateControls() {
return currentView === "ledger" || currentView === "project" || currentView === "team" || currentView === "organization";
}
function syncGlobalDateControlVisibility() {
if (!globalDateControls) return;
globalDateControls.classList.toggle("hidden", !shouldShowGlobalDateControls());
}
function syncGlobalDateControlInputs() {
if (globalStartDateInput) globalStartDateInput.value = globalDateState.startDate || "";
if (globalEndDateInput) globalEndDateInput.value = globalDateState.endDate || "";
}
function getGlobalDateRangePayload() {
return {
source: "total-control",
type: "date-range",
startDate: globalDateState.startDate || "",
endDate: globalDateState.endDate || "",
};
}
function postGlobalDateRangeToFrame(frame) {
if (!frame?.contentWindow || !globalDateState.loaded) return;
frame.contentWindow.postMessage(getGlobalDateRangePayload(), window.location.origin);
}
function notifyEmbeddedTabActivated() {
if (currentView === "project" && projectFrame?.contentWindow) {
projectFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "project" }, window.location.origin);
}
if (currentView === "team" && teamFrame?.contentWindow) {
teamFrame.contentWindow.postMessage({ source: "total-control", type: "tab-activated", tab: "mh" }, window.location.origin);
}
}
async function ensureGlobalDateRangeLoaded() {
if (globalDateState.loaded) return;
try {
const payload = await fetchJson("/api/integration/summary");
const work = payload?.date_ranges?.work || {};
const voucher = payload?.date_ranges?.voucher || {};
const starts = [work.min_work_date, voucher.min_voucher_date].filter(Boolean).sort();
const ends = [work.max_work_date, voucher.max_voucher_date].filter(Boolean).sort();
globalDateState.startDate = starts[0] ? String(starts[0]).slice(0, 10) : "";
globalDateState.endDate = ends.length ? String(ends[ends.length - 1]).slice(0, 10) : "";
globalDateState.loaded = true;
syncGlobalDateControlInputs();
postGlobalDateRangeToFrame(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
} catch (error) {
console.error("공통 기간을 불러오지 못했습니다.", error);
}
}
function hideUserPopover() {
userPopover?.classList.add("hidden");
}
@@ -1063,7 +1140,11 @@ function handleEmbeddedNavigationMessage(event) {
}
async function fetchJson(url, options) {
const response = await fetch(resolveAppUrl(url), options);
const requestOptions = {
...options,
headers: buildAuthHeaders(options?.headers),
};
const response = await fetch(resolveAppUrl(url), requestOptions);
let payload = null;
try {
payload = await response.json();
@@ -1220,6 +1301,9 @@ async function saveSeatLayout() {
body: JSON.stringify({ placements: seatMapState.draftPlacements }),
});
await loadSeatMapData(true);
if (organizationFrame?.contentWindow) {
organizationFrame.contentWindow.postMessage({ type: "seatmap-layout-updated" }, window.location.origin);
}
setSeatMapStatus("자리배치를 저장했습니다.", "success");
} catch (error) {
setSeatMapStatus(error.message || "자리배치도 저장에 실패했습니다.", "error");
@@ -1285,6 +1369,10 @@ function setActiveView(view) {
if (currentViewTitle) {
currentViewTitle.textContent = viewLabels[currentView];
}
syncGlobalDateControlVisibility();
if (shouldShowGlobalDateControls()) {
ensureGlobalDateRangeLoaded();
}
navButtons.forEach((button) => {
const active = button.dataset.view === currentView;
@@ -1293,12 +1381,22 @@ function setActiveView(view) {
});
const isOrganization = currentView === "organization";
const isProject = currentView === "project";
const isTeam = currentView === "team";
const isSeatMapAdmin = currentView === "seatmap-admin";
const isSeatMapReadonly = currentView === "seatmap-readonly";
if (organizationStage) {
organizationStage.hidden = !isOrganization;
organizationStage.style.display = isOrganization ? "flex" : "none";
}
if (projectStage) {
projectStage.hidden = !isProject;
projectStage.style.display = isProject ? "flex" : "none";
}
if (teamStage) {
teamStage.hidden = !isTeam;
teamStage.style.display = isTeam ? "flex" : "none";
}
if (seatMapAdminStage) {
seatMapAdminStage.hidden = !isSeatMapAdmin;
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
@@ -1308,7 +1406,7 @@ function setActiveView(view) {
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
}
if (emptyStage) {
const showEmpty = !isOrganization && !isSeatMapAdmin && !isSeatMapReadonly;
const showEmpty = !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
emptyStage.hidden = !showEmpty;
emptyStage.style.display = showEmpty ? "flex" : "none";
}
@@ -1317,9 +1415,22 @@ function setActiveView(view) {
const frameSrc = organizationFrame.dataset.src || organizationFrame.src;
organizationFrame.src = resolveAppUrl(frameSrc);
}
if (isProject && previousView !== "project" && projectFrame) {
const frameSrc = projectFrame.dataset.src || projectFrame.src;
projectFrame.src = resolveAppUrl(frameSrc);
} else if (isProject) {
postGlobalDateRangeToFrame(projectFrame);
}
if (isTeam && previousView !== "team" && teamFrame) {
const frameSrc = teamFrame.dataset.src || teamFrame.src;
teamFrame.src = resolveAppUrl(frameSrc);
} else if (isTeam) {
postGlobalDateRangeToFrame(teamFrame);
}
if (isSeatMapAdmin || isSeatMapReadonly) {
loadSeatMapData();
}
notifyEmbeddedTabActivated();
}
function renderAuth() {
@@ -1329,7 +1440,7 @@ function renderAuth() {
dashboardPanel.classList.toggle("hidden", !authenticated);
if (authenticated) {
const displayName = session.user.display_name || "접속자";
const rank = "-";
const rank = session.user.rank || "-";
const employeeId = session.user.username || "-";
userBadge.innerHTML = `<span class="user-chip-icon">◎</span><span class="user-chip-text"><strong>${escapeHtml(displayName)}</strong><em>${escapeHtml(rank)}</em></span><span class="user-chip-caret" aria-hidden="true">▾</span>`;
userBadge.title = `${displayName} / -`;
@@ -1363,7 +1474,7 @@ if (loginForm) {
loginMessage.textContent = "로그인 처리 중입니다.";
const formData = new FormData(loginForm);
try {
const payload = await fetchJson("/api/mock-login", {
const payload = await fetchJson("/api/auth/login", {
method: "POST",
body: formData,
});
@@ -1388,14 +1499,51 @@ if (userBadge) {
}
if (logoutBtn) {
logoutBtn.addEventListener("click", (event) => {
logoutBtn.addEventListener("click", async (event) => {
event.stopPropagation();
try {
await fetchJson("/api/auth/logout", {
method: "POST",
});
} catch {
// Ignore logout API errors and clear the local session regardless.
}
clearSession();
hideUserPopover();
renderAuth();
});
}
if (globalStartDateInput) {
globalStartDateInput.addEventListener("change", () => {
globalDateState.startDate = globalStartDateInput.value || "";
postGlobalDateRangeToFrame(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
});
}
if (globalEndDateInput) {
globalEndDateInput.addEventListener("change", () => {
globalDateState.endDate = globalEndDateInput.value || "";
postGlobalDateRangeToFrame(projectFrame);
postGlobalDateRangeToFrame(teamFrame);
});
}
projectFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(projectFrame);
if (currentView === "project") {
notifyEmbeddedTabActivated();
}
});
teamFrame?.addEventListener("load", () => {
postGlobalDateRangeToFrame(teamFrame);
if (currentView === "team") {
notifyEmbeddedTabActivated();
}
});
navButtons.forEach((button) => {
button.addEventListener("click", () => {
hideUserPopover();
@@ -1549,6 +1697,7 @@ document.addEventListener("click", () => {
window.addEventListener("message", handleEmbeddedNavigationMessage);
syncGlobalDateControlInputs();
setActiveView(currentView);
renderAuth();

View File

@@ -8,7 +8,7 @@
<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 rel="stylesheet" href="/legacy/static/common.css">
<link rel="stylesheet" href="/styles.css?v=20260325-11">
<link rel="stylesheet" href="/styles.css?v=20260326-01">
</head>
<body>
<section id="login-panel" class="login-screen">
@@ -37,35 +37,60 @@
<section id="dashboard-panel" class="dashboard-shell hidden">
<header class="dashboard-header">
<div class="brand-block">
<p class="eyebrow">MH Dashboard</p>
<h2 id="current-view-title">조직 현황</h2>
<div class="header-left">
<div class="brand-block">
<p class="eyebrow">MH Dashboard</p>
<h2 id="current-view-title">조직 현황</h2>
</div>
<div id="global-date-controls" class="header-date-controls hidden">
<span class="header-date-label">기간</span>
<label class="header-date-field">
<input id="global-start-date" type="date" aria-label="시작일">
</label>
<span class="header-date-sep">~</span>
<label class="header-date-field">
<input id="global-end-date" type="date" aria-label="종료일">
</label>
</div>
</div>
<div class="header-center">
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
</div>
<div class="header-right">
<div class="header-center">
<button class="nav-pill" type="button" data-view="ledger">사업관리대장</button>
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
</div>
<div class="header-actions">
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
<div id="user-popover" class="user-popover hidden"></div>
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
<div class="header-actions">
<button id="user-badge" class="ghost-button ghost-button-soft user-chip" type="button"></button>
<div id="user-popover" class="user-popover hidden"></div>
<button id="logout-btn" class="ghost-button icon-button" type="button" title="로그아웃" aria-label="로그아웃">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</button>
</div>
</div>
</header>
<main class="dashboard-main">
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<iframe id="organization-frame" src="/legacy/organization?v=20260325-11" data-src="/legacy/organization?v=20260325-11" title="조직도 메인 화면"></iframe>
<iframe id="organization-frame" src="/legacy/organization?v=20260326-02" data-src="/legacy/organization?v=20260326-02" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="project-frame" src="/integrations/payment" data-src="/integrations/payment" title="프로젝트별 분석 화면"></iframe>
</div>
</section>
<section id="team-stage" class="main-stage" hidden>
<div class="stage-frame">
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
</div>
</section>
<section id="seatmap-admin-stage" class="main-stage" hidden>
@@ -175,6 +200,6 @@
</main>
</section>
<script src="/app.js?v=20260325-11"></script>
<script src="/app.js?v=20260326-02"></script>
</body>
</html>

View File

@@ -180,12 +180,35 @@ body {
backdrop-filter: blur(12px);
}
.header-left,
.header-right,
.brand-block,
.header-actions {
position: relative;
z-index: 2;
}
.header-left {
display: flex;
align-items: center;
gap: 18px;
flex: 0 0 auto;
min-width: 0;
}
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
}
.brand-block {
flex: 0 0 auto;
width: 180px;
}
.dashboard-header .eyebrow {
color: var(--color-accent);
margin-bottom: 2px;
@@ -199,14 +222,13 @@ body {
}
.header-center {
margin-left: auto;
margin-right: 48px;
display: inline-flex;
justify-content: center;
justify-content: flex-end;
gap: 24px;
flex-wrap: nowrap;
white-space: nowrap;
z-index: 1;
flex: 0 0 auto;
}
.nav-pill {
@@ -246,6 +268,48 @@ body {
align-items: center;
gap: 6px;
position: relative;
padding-left: 18px;
border-left: 1px solid #dbe2ea;
}
.header-date-controls {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 0;
flex: 0 0 auto;
min-width: 0;
}
.header-date-label {
font-size: 12px;
font-weight: 800;
color: #64748b;
}
.header-date-field {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 10px;
border: 1px solid #dbe2ea;
border-radius: 999px;
background: #fff;
}
.header-date-field input {
border: 0;
background: transparent;
color: var(--color-text);
font-size: 12px;
font-weight: 700;
outline: none;
}
.header-date-sep {
color: #94a3b8;
font-size: 12px;
font-weight: 800;
}
.ghost-button {
@@ -1139,18 +1203,37 @@ body {
justify-content: space-between;
}
.header-left,
.header-right {
width: 100%;
}
.header-right {
justify-content: space-between;
}
.header-center {
position: static;
transform: none;
order: 3;
width: 100%;
justify-content: flex-start;
order: 2;
width: auto;
justify-content: flex-end;
margin-top: 8px;
overflow-x: auto;
}
.header-actions {
flex-wrap: wrap;
padding-left: 0;
border-left: 0;
}
.header-date-controls {
order: 2;
width: auto;
justify-content: flex-start;
margin-left: 0;
margin-top: 8px;
}
.seatmap-content {