Update intranet tools and voucher comparison
This commit is contained in:
+398
-33
@@ -2640,6 +2640,42 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.project-job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
max-width: 300px;
|
||||
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;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="queued"],
|
||||
.project-job-status[data-state="running"] {
|
||||
border-color: #bae6fd;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="done"] {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.project-job-status[data-state="failed"] {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2654,6 +2690,8 @@
|
||||
<div class="project-quick-link-bar" id="projectQuickLinkBar"></div>
|
||||
</div>
|
||||
<div class="toolbar-head-actions">
|
||||
<button type="button" id="projectRebuildCacheBtn" class="button-secondary">캐시 재계산</button>
|
||||
<span class="project-job-status" id="projectJobStatus" data-state="">작업 상태 확인 전</span>
|
||||
<button type="button" id="saveProjectPageState" class="button-icon button-secondary" title="페이지 입력사항 저장" aria-label="페이지 입력사항 저장">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 4h10l4 4v12H6z"></path>
|
||||
@@ -3313,6 +3351,12 @@
|
||||
<div style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<label for="laborRateYearSelect" style="font-size:12px; color:var(--muted);">연도</label>
|
||||
<select id="laborRateYearSelect" class="inline-input" style="min-width:96px;"></select>
|
||||
<label for="laborRateCategorySelect" style="font-size:12px; color:var(--muted);">구분</label>
|
||||
<select id="laborRateCategorySelect" class="inline-input" style="min-width:96px;">
|
||||
<option value="설계">설계</option>
|
||||
<option value="감리">감리</option>
|
||||
<option value="지원">지원</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="button-secondary button-icon" id="saveLaborRateYearButton" title="기준인건비 연도 저장" aria-label="기준인건비 연도 저장">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -3340,7 +3384,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>직급</th>
|
||||
<th>기준인건비(연도별)</th>
|
||||
<th>기준인건비(연도/구분별)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="laborRateRows"></tbody>
|
||||
@@ -3659,6 +3703,8 @@
|
||||
const costDepartmentOptions = {{ cost_department_options | tojson }};
|
||||
const costAccountOptions = {{ cost_account_options | tojson }};
|
||||
const laborGradeOptions = {{ labor_grade_options | tojson }};
|
||||
const laborRateCategories = ["설계", "감리", "지원"];
|
||||
const derivedLaborRateGrades = ["수석", "책임", "선임", "연구원"];
|
||||
const uncontractedCategoryOptions = {{ uncontracted_category_options | tojson }};
|
||||
const specialXClassificationRules = {{ special_x_classification_rules | tojson }};
|
||||
const projectRuntimeSettings = {{ project_runtime_settings | tojson }};
|
||||
@@ -3676,12 +3722,15 @@
|
||||
const persistedProjectPageState = {{ project_page_state | tojson }};
|
||||
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
|
||||
const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }};
|
||||
const defaultExecLaborRates = {{ default_exec_labor_rates | tojson }};
|
||||
const projectPageSessionId = window.clientSessionId || "";
|
||||
let projectStatusMap = {};
|
||||
let projectAccountBreakdowns = {};
|
||||
let projectAccountBreakdownsLoaded = false;
|
||||
let projectAccountBreakdownsLoadedCodes = new Set();
|
||||
let projectAccountBreakdownsPromise = null;
|
||||
let projectJobPollTimer = null;
|
||||
let projectBootstrapLoadError = "";
|
||||
let projectCostMap = {};
|
||||
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
|
||||
window.__projectRelatedSelections = new Map();
|
||||
@@ -3731,6 +3780,7 @@
|
||||
const laborRateModal = document.getElementById("laborRateModal");
|
||||
const laborRateRows = document.getElementById("laborRateRows");
|
||||
const laborRateYearSelect = document.getElementById("laborRateYearSelect");
|
||||
const laborRateCategorySelect = document.getElementById("laborRateCategorySelect");
|
||||
const saveLaborRateYearButton = document.getElementById("saveLaborRateYearButton");
|
||||
const openLaborRateModalButton = document.getElementById("openLaborRateModal");
|
||||
const closeLaborRateModalButton = document.getElementById("closeLaborRateModal");
|
||||
@@ -4347,20 +4397,68 @@
|
||||
const source = rawRates && typeof rawRates === "object" ? rawRates : {};
|
||||
const next = {};
|
||||
const defaultYear = getDefaultLaborRateYear();
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
const normalizeGradeName = (grade) => {
|
||||
const text = String(grade || "").replace(/\s+/g, "");
|
||||
if (text === "전무이사") return "전무";
|
||||
if (text === "상무이사") return "상무";
|
||||
return text;
|
||||
};
|
||||
const normalizeCategory = (category) => {
|
||||
const text = String(category || "").trim();
|
||||
if (text.includes("감리")) return "감리";
|
||||
if (text.includes("지원")) return "지원";
|
||||
if (text.includes("설계")) return "설계";
|
||||
if (text === "supervision") return "감리";
|
||||
if (text === "support") return "지원";
|
||||
return "설계";
|
||||
};
|
||||
const applySource = (rateSource, overwrite = true) => {
|
||||
Object.entries(rateSource || {}).forEach(([key, value]) => {
|
||||
if (/^\d{4}$/.test(String(key || "")) && value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const yearBucket = {};
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
yearBucket[grade] = Number(value?.[grade] || 0);
|
||||
});
|
||||
next[String(key)] = yearBucket;
|
||||
const yearKey = String(key);
|
||||
const yearBucket = next[yearKey] || { 설계: {}, 감리: {}, 지원: {} };
|
||||
const hasCategoryBucket = Object.entries(value).some(([, bucket]) => bucket && typeof bucket === "object" && !Array.isArray(bucket));
|
||||
if (hasCategoryBucket) {
|
||||
Object.entries(value).forEach(([categoryKey, categoryBucket]) => {
|
||||
if (!categoryBucket || typeof categoryBucket !== "object" || Array.isArray(categoryBucket)) return;
|
||||
const category = normalizeCategory(categoryKey);
|
||||
yearBucket[category] = yearBucket[category] || {};
|
||||
Object.entries(categoryBucket).forEach(([grade, amount]) => {
|
||||
const normalizedGrade = normalizeGradeName(grade);
|
||||
if (normalizedGrade) {
|
||||
const numericAmount = Number(amount || 0);
|
||||
if (numericAmount > 0 && (overwrite || !yearBucket[category][normalizedGrade])) {
|
||||
yearBucket[category][normalizedGrade] = numericAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
yearBucket.설계 = yearBucket.설계 || {};
|
||||
Object.entries(value).forEach(([grade, amount]) => {
|
||||
const normalizedGrade = normalizeGradeName(grade);
|
||||
if (normalizedGrade) {
|
||||
const numericAmount = Number(amount || 0);
|
||||
if (numericAmount > 0 && (overwrite || !yearBucket.설계[normalizedGrade])) {
|
||||
yearBucket.설계[normalizedGrade] = numericAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!Object.keys(yearBucket.지원).length) {
|
||||
yearBucket.지원 = { ...yearBucket.감리 };
|
||||
}
|
||||
next[yearKey] = yearBucket;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
applySource(defaultExecLaborRates, true);
|
||||
applySource(source, true);
|
||||
const hasYearBuckets = Object.keys(next).length > 0;
|
||||
if (!hasYearBuckets) {
|
||||
const migrated = {};
|
||||
const migrated = { 설계: {}, 감리: {}, 지원: {} };
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
migrated[grade] = Number(source?.[grade] || 0);
|
||||
migrated.설계[grade] = Number(source?.[grade] || 0);
|
||||
});
|
||||
next[defaultYear] = migrated;
|
||||
}
|
||||
@@ -4416,15 +4514,39 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getResolvedLaborRatesForYear(year = "") {
|
||||
function normalizeLaborRateCategory(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (text.includes("감리")) return "감리";
|
||||
if (text.includes("지원")) return "지원";
|
||||
if (text.includes("설계")) return "설계";
|
||||
return "설계";
|
||||
}
|
||||
|
||||
function getLaborRateLookupCategory(value) {
|
||||
const category = normalizeLaborRateCategory(value);
|
||||
return category === "지원" ? "감리" : category;
|
||||
}
|
||||
|
||||
function getCurrentLaborRateCategory() {
|
||||
return normalizeLaborRateCategory(projectTypeInput?.value || laborRateCategorySelect?.value || "설계");
|
||||
}
|
||||
|
||||
function getLaborRateEditCategory() {
|
||||
return getLaborRateLookupCategory(laborRateCategorySelect?.value || getCurrentLaborRateCategory());
|
||||
}
|
||||
|
||||
function getResolvedLaborRatesForYear(year = "", category = "") {
|
||||
const yearKey = String(year || getDefaultLaborRateYear());
|
||||
const storedYearRates = currentLaborRates?.[yearKey] || {};
|
||||
const lookupCategory = getLaborRateLookupCategory(category || getCurrentLaborRateCategory());
|
||||
const categoryRates = storedYearRates?.[lookupCategory] || storedYearRates?.설계 || {};
|
||||
const designRates = storedYearRates?.설계 || {};
|
||||
const resolvedYearRates = {};
|
||||
laborGradeOptions.forEach((grade) => {
|
||||
resolvedYearRates[grade] = getNumericYearRate(storedYearRates, grade);
|
||||
resolvedYearRates[grade] = getNumericYearRate(categoryRates, grade);
|
||||
});
|
||||
["수석", "책임", "선임", "연구원"].forEach((grade) => {
|
||||
const derived = getDerivedLaborRate(storedYearRates, grade);
|
||||
derivedLaborRateGrades.forEach((grade) => {
|
||||
const derived = getDerivedLaborRate(designRates, grade);
|
||||
if (derived > 0) {
|
||||
resolvedYearRates[grade] = derived;
|
||||
}
|
||||
@@ -4432,9 +4554,9 @@
|
||||
return resolvedYearRates;
|
||||
}
|
||||
|
||||
function getLaborRate(grade, year = "") {
|
||||
function getLaborRate(grade, year = "", category = "") {
|
||||
const yearKey = String(year || getDefaultLaborRateYear());
|
||||
const yearRates = getResolvedLaborRatesForYear(yearKey);
|
||||
const yearRates = getResolvedLaborRatesForYear(yearKey, category);
|
||||
const direct = Number(yearRates?.[grade] || 0);
|
||||
if (direct) return direct;
|
||||
const gradeIndex = laborGradeOptions.indexOf(grade);
|
||||
@@ -4446,8 +4568,8 @@
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateLaborAmount(grade, hours, year = "") {
|
||||
return getLaborRate(grade, year) * parseHours(hours);
|
||||
function calculateLaborAmount(grade, hours, year = "", category = "") {
|
||||
return getLaborRate(grade, year, category) * parseHours(hours);
|
||||
}
|
||||
|
||||
function formatDateInputValue(value) {
|
||||
@@ -5121,12 +5243,13 @@
|
||||
function renderLaborRateRows() {
|
||||
if (!laborRateRows) return;
|
||||
const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const yearRates = currentLaborRates?.[selectedRateYear] || {};
|
||||
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear);
|
||||
const selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
|
||||
const editCategory = getLaborRateLookupCategory(selectedCategory);
|
||||
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear, selectedCategory);
|
||||
laborRateRows.innerHTML = laborGradeOptions.map((grade) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(grade)}</td>
|
||||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(resolvedYearRates?.[grade] || ""))}" ${["수석", "책임", "선임", "연구원"].includes(grade) ? 'readonly title="자동 산출"' : ""}></td>
|
||||
<td><input class="inline-input amount-input formatted-amount-input labor-rate-input" type="text" inputmode="numeric" data-grade="${escapeHtml(grade)}" value="${escapeHtml(formatAmountInputValue(resolvedYearRates?.[grade] || ""))}" ${derivedLaborRateGrades.includes(grade) ? 'readonly title="설계 단가 기준 자동 산출"' : ""}></td>
|
||||
</tr>
|
||||
`).join("");
|
||||
bindCollectionFieldBehaviors(laborRateRows);
|
||||
@@ -5135,8 +5258,13 @@
|
||||
input.addEventListener("input", () => {
|
||||
const grade = input.dataset.grade || "";
|
||||
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
|
||||
nextYearRates[grade] = parseAmount(input.value);
|
||||
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
|
||||
nextCategoryRates[grade] = parseAmount(input.value);
|
||||
nextYearRates[editCategory] = nextCategoryRates;
|
||||
if (editCategory === "감리") {
|
||||
nextYearRates.지원 = { ...nextCategoryRates };
|
||||
}
|
||||
currentLaborRates[targetYear] = nextYearRates;
|
||||
writeLaborRates(currentLaborRates);
|
||||
recalculateExecLaborAmounts();
|
||||
@@ -5156,26 +5284,34 @@
|
||||
laborRateYearSelect.value = activeYear;
|
||||
}
|
||||
}
|
||||
if (laborRateCategorySelect) {
|
||||
laborRateCategorySelect.value = getCurrentLaborRateCategory();
|
||||
}
|
||||
renderLaborRateRows();
|
||||
laborRateModal?.classList.add("open");
|
||||
}
|
||||
|
||||
function saveLaborRateYear() {
|
||||
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
|
||||
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const selectedCategory = laborRateCategorySelect?.value || getCurrentLaborRateCategory();
|
||||
const editCategory = getLaborRateLookupCategory(selectedCategory);
|
||||
const nextYearRates = { 설계: {}, 감리: {}, 지원: {}, ...(currentLaborRates?.[targetYear] || {}) };
|
||||
const nextCategoryRates = { ...(nextYearRates?.[editCategory] || {}) };
|
||||
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
|
||||
const grade = String(input.dataset.grade || "").trim();
|
||||
if (!grade || input.hasAttribute("readonly")) return;
|
||||
nextYearRates[grade] = parseAmount(input.value);
|
||||
});
|
||||
["수석", "책임", "선임", "연구원"].forEach((grade) => {
|
||||
nextYearRates[grade] = getDerivedLaborRate(nextYearRates, grade);
|
||||
nextCategoryRates[grade] = parseAmount(input.value);
|
||||
});
|
||||
nextYearRates[editCategory] = nextCategoryRates;
|
||||
if (editCategory === "감리") {
|
||||
nextYearRates.지원 = { ...nextCategoryRates };
|
||||
}
|
||||
currentLaborRates[targetYear] = nextYearRates;
|
||||
writeLaborRates(currentLaborRates);
|
||||
recalculateExecLaborAmounts();
|
||||
recalculateActualLaborAmounts();
|
||||
window.alert(`${targetYear}년 기준인건비를 저장했습니다.`);
|
||||
renderLaborRateRows();
|
||||
window.alert(`${targetYear}년 ${selectedCategory} 기준인건비를 저장했습니다.`);
|
||||
}
|
||||
|
||||
function closeLaborRateModal() {
|
||||
@@ -5660,6 +5796,14 @@
|
||||
laborRateYearSelect?.addEventListener("change", () => {
|
||||
renderLaborRateRows();
|
||||
});
|
||||
laborRateCategorySelect?.addEventListener("change", () => {
|
||||
renderLaborRateRows();
|
||||
});
|
||||
projectTypeInput?.addEventListener("input", () => {
|
||||
recalculateExecLaborAmounts();
|
||||
recalculateActualLaborAmounts();
|
||||
renderLaborRateRows();
|
||||
});
|
||||
laborRateModal?.addEventListener("click", (event) => {
|
||||
if (event.target === laborRateModal) {
|
||||
closeLaborRateModal();
|
||||
@@ -5990,6 +6134,47 @@
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
const projectStatusDetailRequests = new Map();
|
||||
|
||||
async function ensureProjectStatusDetail(code) {
|
||||
const normalizedCode = String(code || "").trim();
|
||||
if (!normalizedCode) return null;
|
||||
const current = projectStatusMap[normalizedCode] || null;
|
||||
if (current && current._detail_loaded) return current;
|
||||
if (!projectStatusDetailRequests.has(normalizedCode)) {
|
||||
projectStatusDetailRequests.set(
|
||||
normalizedCode,
|
||||
fetch(`/projects/status-detail?code=${encodeURIComponent(normalizedCode)}`, {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { "Accept": "application/json" },
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.ok === false || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
const item = payload?.item || null;
|
||||
if (item?.support_dept_code) {
|
||||
item._detail_loaded = true;
|
||||
projectStatusMap[item.support_dept_code] = item;
|
||||
const index = projectStatusRows.findIndex((row) => row.support_dept_code === item.support_dept_code);
|
||||
if (index >= 0) {
|
||||
projectStatusRows[index] = item;
|
||||
} else {
|
||||
projectStatusRows.push(item);
|
||||
}
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.finally(() => {
|
||||
projectStatusDetailRequests.delete(normalizedCode);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return projectStatusDetailRequests.get(normalizedCode);
|
||||
}
|
||||
|
||||
function getSharedInputClusterCodes(item) {
|
||||
if (!Array.isArray(item?.shared_input_cluster_codes)) return [];
|
||||
return item.shared_input_cluster_codes
|
||||
@@ -6136,7 +6321,7 @@
|
||||
return [];
|
||||
}
|
||||
const codeSet = new Set([normalizedBaseCode]);
|
||||
getRelatedCodes(normalizedBaseCode).forEach((code) => {
|
||||
getCurrentRelatedCodes(normalizedBaseCode).forEach((code) => {
|
||||
const normalizedCode = String(code || "").trim();
|
||||
if (normalizedCode) {
|
||||
codeSet.add(normalizedCode);
|
||||
@@ -6228,10 +6413,97 @@
|
||||
return projectAccountBreakdownsPromise;
|
||||
}
|
||||
|
||||
async function fetchProjectJson(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 describeProjectJob(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 renderProjectJob(job) {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (!jobStatus) return;
|
||||
const view = describeProjectJob(job);
|
||||
jobStatus.textContent = view.text;
|
||||
jobStatus.dataset.state = view.state || "";
|
||||
if (rebuildButton) {
|
||||
rebuildButton.disabled = view.state === "queued" || view.state === "running";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestProjectJob() {
|
||||
const params = new URLSearchParams({
|
||||
page_key: "projects",
|
||||
job_type: "projects_bootstrap",
|
||||
});
|
||||
const payload = await fetchProjectJson(`/api/system-jobs/latest?${params.toString()}`);
|
||||
renderProjectJob(payload.job || null);
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
function pollProjectJob(jobId) {
|
||||
if (!jobId) return;
|
||||
if (projectJobPollTimer) window.clearInterval(projectJobPollTimer);
|
||||
projectJobPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchProjectJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
|
||||
const job = payload.job || null;
|
||||
renderProjectJob(job);
|
||||
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
|
||||
window.clearInterval(projectJobPollTimer);
|
||||
projectJobPollTimer = null;
|
||||
if (job && job.status === "done") {
|
||||
window.setTimeout(() => window.location.reload(), 600);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Keep the current project list usable while transient locks clear.
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function requestProjectCacheRebuild() {
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = true;
|
||||
const payload = await fetchProjectJson("/projects/api/rebuild-cache", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year: selectedYear || null }),
|
||||
});
|
||||
renderProjectJob(payload.job || null);
|
||||
pollProjectJob(payload.job?.id);
|
||||
}
|
||||
|
||||
async function loadProjectBootstrapData(force = false) {
|
||||
if (!force && projectStatusRows.length && projectCostRows.length) {
|
||||
return;
|
||||
}
|
||||
projectBootstrapLoadError = "";
|
||||
const query = selectedYear ? `?year=${encodeURIComponent(String(selectedYear))}` : "";
|
||||
const response = await fetch(`/projects/bootstrap-data${query}`, {
|
||||
method: "GET",
|
||||
@@ -9082,6 +9354,17 @@
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
return;
|
||||
}
|
||||
if (item._detail_loaded === false) {
|
||||
ensureProjectStatusDetail(item.support_dept_code)
|
||||
.then((detailItem) => {
|
||||
if (!detailItem) return;
|
||||
if (String(currentSelectedProjectCode || "") !== String(item.support_dept_code || "")) return;
|
||||
renderAnalysis(getAnalysisItem(item.support_dept_code));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("프로젝트 상세 데이터 조회 에러", error);
|
||||
});
|
||||
}
|
||||
const requestedBreakdownCodes = buildProjectBreakdownRequestCodes(item.support_dept_code);
|
||||
if (!hasProjectBreakdownDataForCodes(requestedBreakdownCodes)) {
|
||||
relatedBarBox.innerHTML = "";
|
||||
@@ -9284,8 +9567,42 @@
|
||||
},
|
||||
};
|
||||
|
||||
let projectBootstrapEnsurePromise = null;
|
||||
async function ensureProjectBootstrapData() {
|
||||
if (projectStatusRows.length || projectCostRows.length) {
|
||||
return true;
|
||||
}
|
||||
if (!projectBootstrapEnsurePromise) {
|
||||
projectBootstrapEnsurePromise = loadProjectBootstrapData(true)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error("프로젝트 부트스트랩 데이터 재조회 에러", error);
|
||||
projectBootstrapLoadError = error?.message || "프로젝트 데이터를 불러오지 못했습니다.";
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
projectBootstrapEnsurePromise = null;
|
||||
});
|
||||
}
|
||||
return projectBootstrapEnsurePromise;
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
const { renderDetail = false } = options;
|
||||
if (!projectStatusRows.length && !projectCostRows.length) {
|
||||
dropdownBox?.classList.toggle("open", Boolean((input?.value || "").trim()));
|
||||
listBox.innerHTML = "";
|
||||
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오는 중입니다.";
|
||||
ensureProjectBootstrapData().then((loaded) => {
|
||||
if (loaded) {
|
||||
refresh(options);
|
||||
return;
|
||||
}
|
||||
listBox.innerHTML = "";
|
||||
emptyBox.textContent = projectBootstrapLoadError || "프로젝트 데이터를 불러오지 못했습니다. 캐시 재계산 후 다시 시도해 주세요.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
const keyword = input?.value || "";
|
||||
const dataset = filterProjectCostDatasetByContractState(
|
||||
buildProjectCostDataset(keyword, yearSelect?.value || ""),
|
||||
@@ -9510,9 +9827,10 @@
|
||||
event.preventDefault();
|
||||
excludedItemsButton.click();
|
||||
});
|
||||
listBox?.addEventListener("click", (event) => {
|
||||
listBox?.addEventListener("click", async (event) => {
|
||||
const itemEl = event.target.closest(".explorer-item");
|
||||
if (!itemEl) return;
|
||||
await ensureProjectBootstrapData();
|
||||
selectedCode = itemEl.dataset.code || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
input.value = itemEl.querySelector("strong")?.textContent || input.value;
|
||||
@@ -9521,7 +9839,7 @@
|
||||
renderSuggestionList([], "");
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
});
|
||||
quickLinkBar?.addEventListener("click", (event) => {
|
||||
quickLinkBar?.addEventListener("click", async (event) => {
|
||||
const removeButton = event.target.closest("[data-remove-quick-link]");
|
||||
if (removeButton) {
|
||||
event.stopPropagation();
|
||||
@@ -9530,10 +9848,27 @@
|
||||
}
|
||||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||||
if (!quickLinkButton) return;
|
||||
await ensureProjectBootstrapData();
|
||||
selectedCode = quickLinkButton.dataset.openQuickLink || "";
|
||||
currentSelectedProjectCode = selectedCode;
|
||||
const quickLinkItem = getAnalysisItem(selectedCode);
|
||||
if (quickLinkItem && input) {
|
||||
input.value = quickLinkItem.support_dept_name || "";
|
||||
}
|
||||
setAnalysisVisibility(true);
|
||||
renderAnalysis(getAnalysisItem(selectedCode));
|
||||
if (quickLinkItem) {
|
||||
renderAnalysis(quickLinkItem);
|
||||
} else {
|
||||
relatedBarBox.innerHTML = "";
|
||||
heroBox.innerHTML = "";
|
||||
metricsBox.innerHTML = "";
|
||||
comparisonBox.innerHTML = `
|
||||
<div class="detail-block">
|
||||
<p class="stacked-note">바로가기 프로젝트를 현재 데이터에서 찾지 못했습니다. 프로젝트 정보 캐시를 재계산한 뒤 다시 확인해 주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
notesBox.innerHTML = "";
|
||||
}
|
||||
});
|
||||
quickLinkBar?.addEventListener("keydown", (event) => {
|
||||
const quickLinkButton = event.target.closest("[data-open-quick-link]");
|
||||
@@ -9651,6 +9986,22 @@
|
||||
await saveProjectPageStateToServer(true);
|
||||
});
|
||||
|
||||
document.getElementById("projectRebuildCacheBtn")?.addEventListener("click", async () => {
|
||||
const confirmed = window.confirm("현재 프로젝트 정보 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await requestProjectCacheRebuild();
|
||||
} catch (error) {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = error.message || "작업 등록 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
const rebuildButton = document.getElementById("projectRebuildCacheBtn");
|
||||
if (rebuildButton) rebuildButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
relatedToolbarButton?.addEventListener("click", () => {
|
||||
openRelatedProjectPicker();
|
||||
});
|
||||
@@ -9666,6 +10017,7 @@
|
||||
await loadProjectQuickLinksFromServer();
|
||||
try {
|
||||
await loadProjectBootstrapData();
|
||||
renderProjectQuickLinks();
|
||||
} catch (error) {
|
||||
console.error("프로젝트 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
@@ -9698,6 +10050,19 @@
|
||||
}
|
||||
}
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
loadLatestProjectJob()
|
||||
.then((job) => {
|
||||
if (job && ["queued", "running"].includes(String(job.status || ""))) {
|
||||
pollProjectJob(job.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const jobStatus = document.getElementById("projectJobStatus");
|
||||
if (jobStatus) {
|
||||
jobStatus.textContent = "작업 상태 조회 실패";
|
||||
jobStatus.dataset.state = "failed";
|
||||
}
|
||||
});
|
||||
})();
|
||||
if (openModalButton) {
|
||||
openModalButton.addEventListener("click", async () => {
|
||||
|
||||
Reference in New Issue
Block a user