Files
HM_project_Viewer_Board/templates/base.html
T

872 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}인트라넷 회계 시스템{% endblock %}</title>
<style>
:root {
--bg-a: #f6f6f7;
--bg-b: #ececef;
--panel: rgba(255, 255, 255, 0.94);
--ink: #161616;
--muted: #73777f;
--line: #d9dde3;
--accent: #111111;
--accent-strong: #000000;
--warn: #fff2cb;
--table-alt: #f5f6f8;
--white: #ffffff;
--page-gutter: clamp(14px, 1.8vw, 24px);
--panel-pad: clamp(16px, 1.4vw, 20px);
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
--page-frame-width-dual: min(2560px, calc(100vw - (var(--page-gutter) * 2)));
--page-frame-width-single: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
--status-widget-height: 34px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
::selection {
background: rgba(59, 130, 246, 0.18);
color: var(--ink);
}
::-moz-selection {
background: rgba(59, 130, 246, 0.18);
color: var(--ink);
}
body {
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, var(--bg-a), var(--bg-b));
min-height: 100vh;
padding: var(--page-gutter);
}
body[data-view-mode="single"] {
--page-frame-width: var(--page-frame-width-single);
}
body[data-view-mode="dual"] {
--page-frame-width: var(--page-frame-width-dual);
--page-gutter: clamp(4px, 0.45vw, 10px);
--panel-pad: clamp(12px, 1vw, 16px);
}
.page {
width: min(100%, var(--page-frame-width));
margin: 0 auto;
display: grid;
gap: var(--page-gutter);
min-height: calc(100vh - (var(--page-gutter) * 2));
align-content: start;
}
body[data-view-mode="dual"] .page {
width: calc(100vw - (var(--page-gutter) * 2));
max-width: none;
}
.page > * {
width: 100%;
}
.nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 18px;
padding: 8px;
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
}
.nav-spacer {
flex: 1 1 auto;
min-width: 12px;
}
.nav a {
text-decoration: none;
color: var(--ink);
padding: 9px 13px;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.nav a.active {
background: var(--accent);
color: var(--white);
}
.nav a:hover {
background: #f3f4f6;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: var(--panel-pad);
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
backdrop-filter: blur(6px);
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
font-size: 21px;
letter-spacing: -0.03em;
}
.section-title p {
color: var(--muted);
font-size: 14px;
}
.message {
background: var(--warn);
border: 1px solid #ead98a;
color: #624c0b;
border-radius: 14px;
padding: 14px 16px;
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.stat-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px 15px 13px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
}
.stat-card .label {
color: var(--muted);
font-size: 13px;
margin-bottom: 6px;
letter-spacing: 0.01em;
font-weight: 700;
}
.stat-card .value {
font-size: clamp(23px, 2vw, 38px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.04em;
word-break: break-word;
}
.stat-card .meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.two-col {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: 20px;
}
.stack {
display: grid;
gap: 20px;
}
form {
display: grid;
gap: 14px;
}
.upload-box {
background: linear-gradient(180deg, #fbfbfc, #f1f3f6);
border: 1px dashed #c5ccd6;
border-radius: 14px;
padding: 18px;
}
.upload-box p {
color: var(--muted);
line-height: 1.65;
margin-bottom: 10px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.field-wide {
grid-column: span 2;
}
.field-full {
grid-column: 1 / -1;
}
label {
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
input,
select,
textarea,
button {
font: inherit;
}
input[type="text"],
input[type="number"],
input[type="date"],
input[type="file"],
select,
textarea {
width: 100%;
border: 1px solid var(--line);
background: #fcfcfd;
border-radius: 10px;
padding: 10px 12px;
color: var(--ink);
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03);
box-sizing: border-box;
}
input[type="text"],
input[type="number"],
input[type="date"],
select {
min-height: 38px;
line-height: 1.2;
padding-top: 8px;
padding-bottom: 8px;
}
select option {
color: var(--ink);
background: #ffffff;
}
select option:checked,
select option:hover,
select option:focus {
color: var(--ink);
background: #eef2f6;
box-shadow: inset 0 0 0 999px #eef2f6;
}
textarea {
min-height: 96px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(17, 17, 17, 0.08);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
button,
.button-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 10px;
border: 1px solid transparent;
padding: 9px 14px;
background: var(--accent);
color: var(--white);
font-weight: 700;
font-size: 14px;
cursor: pointer;
text-decoration: none;
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease;
box-shadow: 0 8px 18px rgba(17, 17, 17, 0.14);
min-height: 38px;
box-sizing: border-box;
white-space: nowrap;
line-height: 1.2;
}
button:hover,
.button-link:hover {
background: var(--accent-strong);
transform: translateY(-1px);
}
.button-secondary {
background: #ffffff;
color: #1b1d21;
border-color: var(--line);
box-shadow: none;
}
.button-secondary:hover,
.button-secondary:focus-visible {
background: #eef2f6;
color: #111827;
border-color: #d6dde5;
transform: none;
box-shadow: none;
}
.button-icon {
width: 38px;
height: 38px;
min-width: 38px;
padding: 0;
border-radius: 10px;
box-shadow: none;
flex: 0 0 auto;
}
.button-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.button-icon.button-secondary svg {
stroke: #1b1d21;
}
.button-icon.danger-lite svg {
stroke: #a93a3a;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--white);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 780px;
}
th,
td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
th {
position: sticky;
top: 0;
background: #eff5f7;
color: #345061;
z-index: 1;
}
tbody tr:nth-child(even) td {
background: var(--table-alt);
}
.empty {
padding: 24px;
color: var(--muted);
text-align: center;
}
.search-box {
margin: 16px 0;
}
.mono {
font-family: "Consolas", "Courier New", monospace;
font-size: 13px;
}
.sync-status {
margin-left: auto;
max-width: 240px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(248, 250, 252, 0.92);
box-shadow: none;
min-height: var(--status-widget-height);
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(6px);
}
.view-mode-switch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: var(--status-widget-height);
min-height: var(--status-widget-height);
border-radius: 999px;
border: 1px solid var(--line);
background: #eef2f6;
color: #1f2937;
font-size: 11px;
font-weight: 800;
letter-spacing: -0.01em;
box-shadow: none;
padding: 0;
overflow: hidden;
cursor: pointer;
transition: background-color 0.16s ease, border-color 0.16s ease;
}
.view-mode-switch:hover {
transform: none;
background: #e7ecf2;
}
.view-mode-switch .label {
position: absolute;
top: 50%;
left: 3px;
width: 36px;
transform: translateY(-50%);
text-align: center;
color: #ffffff;
z-index: 2;
pointer-events: none;
user-select: none;
transition: left 0.2s ease;
}
.view-mode-switch::before {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 36px;
height: calc(var(--status-widget-height) - 8px);
border-radius: 999px;
background: #111827;
box-shadow: 0 6px 12px rgba(15, 23, 42, 0.18);
transition: transform 0.2s ease;
z-index: 1;
}
.view-mode-switch[data-mode="dual"]::before {
transform: translateX(38px);
}
.view-mode-switch[data-mode="dual"] .label {
left: 41px;
}
.view-mode-switch[data-mode="single"]::before {
transform: translateX(0);
}
.sync-status-head {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sync-status-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.sync-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #9aa3af;
}
.sync-dot.online {
background: #16a34a;
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
}
.sync-dot.error {
background: #dc2626;
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.12);
}
.sync-pill {
display: none;
}
.sync-meta {
display: none;
}
.sync-meta strong {
color: var(--ink);
font-weight: 700;
}
@media (max-width: 1200px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid,
.two-col {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.stats,
.form-grid {
grid-template-columns: 1fr;
}
.field-wide,
.field-full {
grid-column: auto;
}
.sync-status {
width: 100%;
justify-content: center;
}
}
</style>
{% block head_extra %}{% endblock %}
</head>
<body>
<div class="page">
<nav class="nav">
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
<a href="/wehago-compare" class="{% if request.url.path == '/wehago-compare' %}active{% endif %}">전표비교</a>
<div class="nav-spacer"></div>
<button type="button" class="view-mode-switch" id="viewModeSwitch" data-mode="dual" aria-label="화면 구성 전환">
<span class="label" id="viewModeSwitchLabel">듀얼</span>
</button>
<aside
class="sync-status"
id="syncStatusWidget"
data-data-version="{{ data_version or '' }}"
data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"
data-refresh-mode="{{ 'disabled' if request.url.path == '/projects' else 'auto' }}"
>
<div class="sync-status-head">
<div class="sync-status-title">
<span class="sync-dot" id="syncStatusDot"></span>
<span id="syncStatusLabel">연결 확인 중</span>
</div>
<span class="sync-pill" id="syncSessionPill">세션 준비 중</span>
</div>
<div class="sync-meta">
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
</div>
</aside>
</nav>
{% if message %}
<div class="message">{{ message }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
{% block script %}{% endblock %}
<script>
(() => {
const body = document.body;
const switchButton = document.getElementById("viewModeSwitch");
const switchLabel = document.getElementById("viewModeSwitchLabel");
if (!body || !switchButton || !switchLabel) return;
const MODE_KEY = "intranet-view-mode";
const SOURCE_KEY = "intranet-view-mode-source";
const computeAutoMode = () => {
const width = window.innerWidth || 0;
const height = window.innerHeight || 1;
const ratio = width / Math.max(height, 1);
return (width >= 1450 || ratio >= 1.7) ? "dual" : "single";
};
const applyMode = (mode) => {
const normalized = mode === "single" ? "single" : "dual";
body.dataset.viewMode = normalized;
switchButton.dataset.mode = normalized;
switchButton.setAttribute("aria-pressed", normalized === "single" ? "true" : "false");
switchLabel.textContent = normalized === "single" ? "싱글" : "듀얼";
};
const savedMode = window.localStorage.getItem(MODE_KEY);
const savedSource = window.localStorage.getItem(SOURCE_KEY);
if ((savedMode === "single" || savedMode === "dual") && savedSource === "manual") {
applyMode(savedMode);
} else {
const autoMode = computeAutoMode();
applyMode(autoMode);
window.localStorage.setItem(MODE_KEY, autoMode);
window.localStorage.setItem(SOURCE_KEY, "auto");
}
switchButton.addEventListener("click", () => {
const nextMode = body.dataset.viewMode === "single" ? "dual" : "single";
applyMode(nextMode);
window.localStorage.setItem(MODE_KEY, nextMode);
window.localStorage.setItem(SOURCE_KEY, "manual");
});
})();
(() => {
const widget = document.getElementById("syncStatusWidget");
if (!widget) return;
const dot = document.getElementById("syncStatusDot");
const label = document.getElementById("syncStatusLabel");
const sessionPill = document.getElementById("syncSessionPill");
const lastChecked = document.getElementById("syncLastChecked");
const serverTime = document.getElementById("syncServerTime");
const dataVersion = document.getElementById("syncDataVersion");
let pageVersion = widget.dataset.dataVersion || "";
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
const refreshMode = widget.dataset.refreshMode || "auto";
let refreshInFlight = false;
let pendingVersion = "";
let isFormDirty = false;
function getSessionId() {
const key = "intranet-client-session-id";
let sessionId = window.localStorage.getItem(key);
if (!sessionId) {
sessionId = `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
window.localStorage.setItem(key, sessionId);
}
return sessionId;
}
const clientSessionId = getSessionId();
window.clientSessionId = clientSessionId;
sessionPill.textContent = clientSessionId;
function updateWidgetTitle() {
widget.title = [
`상태: ${label.textContent}`,
`세션: ${sessionPill.textContent}`,
`마지막 확인: ${lastChecked.textContent}`,
`서버 시간: ${serverTime.textContent}`,
`데이터 버전: ${dataVersion.textContent}`,
].join("\n");
}
function prepareCollabForms() {
document.querySelectorAll("form[data-collab-form]").forEach((form) => {
let sessionInput = form.querySelector('input[name="client_session_id"]');
if (!sessionInput) {
sessionInput = document.createElement("input");
sessionInput.type = "hidden";
sessionInput.name = "client_session_id";
form.appendChild(sessionInput);
}
sessionInput.value = clientSessionId;
let submittedInput = form.querySelector('input[name="client_submitted_at"]');
if (!submittedInput) {
submittedInput = document.createElement("input");
submittedInput.type = "hidden";
submittedInput.name = "client_submitted_at";
form.appendChild(submittedInput);
}
const markDirty = () => {
isFormDirty = true;
};
form.addEventListener("input", markDirty);
form.addEventListener("change", markDirty);
form.addEventListener("submit", () => {
isFormDirty = false;
submittedInput.value = new Date().toISOString();
});
});
}
prepareCollabForms();
function setStatus(kind, text) {
dot.classList.remove("online", "error");
if (kind === "online") dot.classList.add("online");
if (kind === "error") dot.classList.add("error");
label.textContent = text;
lastChecked.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
updateWidgetTitle();
}
function setPageDataVersion(nextVersion) {
pageVersion = nextVersion || "";
pendingVersion = "";
widget.dataset.dataVersion = pageVersion;
dataVersion.textContent = pageVersion || "-";
updateWidgetTitle();
}
window.__setPageDataVersion = setPageDataVersion;
function hasActiveEditor() {
const active = document.activeElement;
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
}
function hasOpenModal() {
return Boolean(document.querySelector(".modal-backdrop.open"));
}
function shouldDelayRefresh() {
return isFormDirty || hasActiveEditor() || hasOpenModal() || window.__suspendAutoRefresh === true;
}
async function refreshPageWhenSafe(nextVersion) {
if (refreshInFlight) return;
if (refreshMode === "disabled") {
setPageDataVersion(nextVersion || pageVersion);
setStatus("online", "서버 정상 연결");
return;
}
if (shouldDelayRefresh()) {
pendingVersion = nextVersion || pendingVersion || pageVersion;
setStatus("online", "새 데이터 대기 중");
return;
}
refreshInFlight = true;
pendingVersion = nextVersion || pendingVersion || "";
setStatus("online", "새 데이터 반영 중");
try {
window.location.replace(refreshUrl);
} catch (error) {
refreshInFlight = false;
setStatus("error", "업데이트 재시도 중");
}
}
async function pollHealth() {
if (document.hidden) {
return;
}
try {
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
serverTime.textContent = payload.server_time || "-";
dataVersion.textContent = payload.data_version || "-";
updateWidgetTitle();
if (payload.data_version && payload.data_version !== pageVersion) {
pendingVersion = payload.data_version;
await refreshPageWhenSafe(payload.data_version);
return;
}
if (pendingVersion && pendingVersion !== pageVersion) {
await refreshPageWhenSafe(pendingVersion);
return;
}
setStatus("online", "서버 정상 연결");
} catch (error) {
setStatus("error", "연결 오류");
}
}
pollHealth();
updateWidgetTitle();
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
pollHealth();
}
});
window.setInterval(() => {
if (!document.hidden) {
pollHealth();
}
}, 15000);
})();
</script>
</body>
</html>