Update intranet tools and voucher comparison

This commit is contained in:
b17301
2026-06-04 09:00:51 +09:00
parent 2ab74bac88
commit b93289bfa8
55 changed files with 32809 additions and 1063 deletions
+154
View File
@@ -32,6 +32,42 @@
align-items: center;
}
.page-job-status {
display: inline-flex;
align-items: center;
min-height: 32px;
max-width: 320px;
padding: 0 10px;
border: 1px solid #d8e2ec;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-job-status[data-state="queued"],
.page-job-status[data-state="running"] {
border-color: #bae6fd;
background: #f0f9ff;
color: #075985;
}
.page-job-status[data-state="done"] {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.page-job-status[data-state="failed"] {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.upload-actions:hover .upload-tooltip,
.upload-actions:focus-within .upload-tooltip {
opacity: 1;
@@ -266,6 +302,8 @@
{% endfor %}
</select>
</form>
<button type="button" class="button-secondary" id="dashboardRebuildCacheBtn">캐시 재계산</button>
<span class="page-job-status" id="dashboardJobStatus" data-state="">작업 상태 확인 전</span>
<div class="upload-actions">
<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
<input id="excel_file" class="hidden-file-input" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
@@ -382,6 +420,7 @@
let monthlySummary = [];
let revenueYearly = [];
let revenueMonthly = [];
let dashboardJobPollTimer = null;
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
@@ -548,6 +587,92 @@
return String(availableYears[availableYears.length - 1] || "all");
}
async function fetchDashboardJson(url, options = {}) {
const response = await fetch(url, {
...options,
cache: "no-store",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Cache-Control": "no-cache",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error || payload?.ok === false) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
return payload;
}
function describeDashboardJob(job) {
if (!job) return { text: "최근 작업 없음", state: "" };
const status = String(job.status || "");
const range = job.start_year ? String(job.start_year) : "전체";
const message = job.error_message || job.message || "";
if (status === "queued") return { text: `${range} 대기 중`, state: status };
if (status === "running") return { text: `${range} 계산 중... ${message}`.trim(), state: status };
if (status === "done") return { text: `${range} 계산 완료`, state: status };
if (status === "failed") return { text: `${range} 실패: ${message || "오류"}`, state: status };
return { text: `${range} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
}
function renderDashboardJob(job) {
const jobStatus = document.getElementById("dashboardJobStatus");
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (!jobStatus) return;
const view = describeDashboardJob(job);
jobStatus.textContent = view.text;
jobStatus.dataset.state = view.state || "";
if (rebuildButton) {
rebuildButton.disabled = view.state === "queued" || view.state === "running";
}
}
async function loadLatestDashboardJob() {
const params = new URLSearchParams({
page_key: "dashboard",
job_type: "dashboard_bootstrap",
});
const payload = await fetchDashboardJson(`/api/system-jobs/latest?${params.toString()}`);
renderDashboardJob(payload.job || null);
return payload.job || null;
}
function pollDashboardJob(jobId) {
if (!jobId) return;
if (dashboardJobPollTimer) window.clearInterval(dashboardJobPollTimer);
dashboardJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchDashboardJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
const job = payload.job || null;
renderDashboardJob(job);
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
window.clearInterval(dashboardJobPollTimer);
dashboardJobPollTimer = null;
if (job && job.status === "done") {
window.setTimeout(() => window.location.reload(), 600);
}
}
} catch (_error) {
// Transient DB locks should not block the dashboard controls.
}
}, 2500);
}
async function requestDashboardCacheRebuild() {
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = true;
const payload = await fetchDashboardJson("/dashboard/api/rebuild-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overview_year: pageSelectedYear || null }),
});
renderDashboardJob(payload.job || null);
pollDashboardJob(payload.job?.id);
}
function syncYearSelection(selectId, granularity) {
const select = document.getElementById(selectId);
if (!select) return;
@@ -645,6 +770,22 @@
event.target.form?.submit();
});
document.getElementById("dashboardRebuildCacheBtn")?.addEventListener("click", async () => {
const confirmed = window.confirm("현재 대시보드 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestDashboardCacheRebuild();
} catch (error) {
const jobStatus = document.getElementById("dashboardJobStatus");
if (jobStatus) {
jobStatus.textContent = error.message || "작업 등록 실패";
jobStatus.dataset.state = "failed";
}
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = false;
}
});
const uploadButton = document.getElementById("uploadButton");
const excelInput = document.getElementById("excel_file");
const uploadForm = document.getElementById("uploadForm");
@@ -669,6 +810,19 @@
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
updateRevenueChart();
updateExpenseChart();
loadLatestDashboardJob()
.then((job) => {
if (job && ["queued", "running"].includes(String(job.status || ""))) {
pollDashboardJob(job.id);
}
})
.catch(() => {
const jobStatus = document.getElementById("dashboardJobStatus");
if (jobStatus) {
jobStatus.textContent = "작업 상태 조회 실패";
jobStatus.dataset.state = "failed";
}
});
})();
</script>
{% endblock %}