Update dashboard and project info workflows
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
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: 14px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1520px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.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: 18px;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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-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;
|
||||
padding: 6px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.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) {
|
||||
body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.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>
|
||||
<div class="nav-spacer"></div>
|
||||
<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 %}"
|
||||
>
|
||||
<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>
|
||||
</aside>
|
||||
</nav>
|
||||
|
||||
{% if message %}
|
||||
<div class="message">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% block script %}{% endblock %}
|
||||
<script>
|
||||
(() => {
|
||||
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;
|
||||
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();
|
||||
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 hasActiveEditor() {
|
||||
const active = document.activeElement;
|
||||
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
|
||||
}
|
||||
|
||||
function shouldDelayRefresh() {
|
||||
return isFormDirty || hasActiveEditor();
|
||||
}
|
||||
|
||||
async function refreshPageWhenSafe(nextVersion) {
|
||||
if (refreshInFlight) return;
|
||||
if (shouldDelayRefresh()) {
|
||||
pendingVersion = nextVersion || pendingVersion || pageVersion;
|
||||
setStatus("online", "새 데이터 대기 중");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlight = true;
|
||||
pendingVersion = nextVersion || pendingVersion || "";
|
||||
setStatus("online", "새 데이터 반영 중");
|
||||
|
||||
try {
|
||||
const response = await fetch(refreshUrl, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const html = await response.text();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} catch (error) {
|
||||
refreshInFlight = false;
|
||||
setStatus("error", "업데이트 재시도 중");
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealth() {
|
||||
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(pollHealth, 15000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user