Update project pages and add DB browser

This commit is contained in:
b17301
2026-05-08 23:51:20 +09:00
parent 99137e5f97
commit b0b4647bb4
4 changed files with 556 additions and 190 deletions
+108 -124
View File
@@ -146,8 +146,7 @@
min-width: 0;
}
.pc-quick-link,
.pc-related-link {
.pc-quick-link {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -165,8 +164,7 @@
}
.pc-quick-link strong,
.pc-quick-link span,
.pc-related-link span {
.pc-quick-link span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
@@ -422,6 +420,10 @@
line-height: 1.15;
}
.pc-card .value.negative {
color: #dc2626;
}
.pc-card .meta {
font-size: 12px;
color: var(--muted);
@@ -826,24 +828,6 @@
<div class="pc-selector-field">
<input type="text" id="projectSearchInput" class="pc-project-search" placeholder="프로젝트 코드/명 검색" autocomplete="off">
<div class="pc-project-dropdown" id="projectList" hidden>
{% for item in process_cost_projects %}
<a
href="/process-cost?source={{ process_cost_source }}{% if process_cost_selected_start_year %}&start_year={{ process_cost_selected_start_year }}{% endif %}{% if process_cost_selected_end_year %}&end_year={{ process_cost_selected_end_year }}{% endif %}&code={{ item.support_dept_code }}{% if process_cost_include_related %}&include_related=1&active_related={% if process_cost_active_related_codes %}{{ process_cost_active_related_codes|join(',') }}{% else %}-{% endif %}{% endif %}"
class="pc-project-item {% if process_cost_selected_code == item.support_dept_code %}active{% endif %}"
data-project-search="{{ (item.support_dept_code ~ ' ' ~ item.support_dept_name ~ ' ' ~ (item.client_name or ''))|lower }}"
data-project-kind="{{ item.project_kind_code|lower }}"
title="{{ item.support_dept_code }} {{ item.support_dept_name }}"
>
<div class="pc-project-head">
<span class="pc-project-code">{{ item.support_dept_code }}</span>
<span class="pc-type-pill">{{ item.project_kind_label }}</span>
</div>
<div class="pc-project-name">{{ item.support_dept_name }}</div>
{% if item.client_name %}
<div class="pc-project-name">{{ item.client_name }}</div>
{% endif %}
</a>
{% endfor %}
<div class="pc-note" id="projectListEmpty" hidden style="padding: 12px;">검색 결과가 없습니다.</div>
</div>
</div>
@@ -906,7 +890,7 @@
</article>
<article class="pc-card">
<span class="label">영업수지</span>
<strong class="value">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong>
<strong class="value {% if (overview.profit_amount|default(0)) < 0 %}negative{% endif %}">{{ "{:,.0f}".format(overview.profit_amount|default(0)) }}원</strong>
<span class="meta">마진율 {{ "{:.1f}".format(overview.profit_rate|default(0)) }}%</span>
</article>
</div>
@@ -935,7 +919,6 @@
{% for related_code in process_cost_related_codes or [] %}
<span class="pc-chip" data-related-code="{{ related_code }}">
<span>{{ related_code }}</span>
<button type="button" class="pc-related-remove" data-related-remove="{{ related_code }}">x</button>
</span>
{% endfor %}
</div>
@@ -1065,25 +1048,50 @@
<div class="pc-note" id="processFlowModalNote"></div>
</div>
</div>
<script id="processCostPageState" type="application/json">
{{ {
"projects": process_cost_projects or [],
"selectedCode": process_cost_selected_code,
"selectedProject": process_cost_selected_project or {},
"relatedCodes": process_cost_related_codes or [],
"activeRelatedCodes": process_cost_active_related_codes or [],
"quickLinkCodes": process_cost_quick_link_codes or [],
"source": process_cost_source,
"selectedStartYear": process_cost_selected_start_year,
"selectedEndYear": process_cost_selected_end_year,
"includeRelatedEnabled": process_cost_include_related,
"monthlyRows": (process_cost_detail.monthly_rows or []),
} | tojson }}
</script>
{% endblock %}
{% block script %}
<script>
(() => {
const processCostProjects = {{ process_cost_projects | tojson }};
const stateNode = document.getElementById("processCostPageState");
const pageState = stateNode ? JSON.parse(stateNode.textContent || "{}") : {};
const processCostProjects = Array.isArray(pageState.projects) ? pageState.projects : [];
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const processCostProjectMap = new Map(
processCostProjects
.filter((item) => item && item.support_dept_code)
.map((item) => [item.support_dept_code, item])
);
const selectedCode = {{ process_cost_selected_code | tojson }};
const selectedProject = {{ (process_cost_selected_project or {}) | tojson }};
const initialRelatedCodes = {{ (process_cost_related_codes or []) | tojson }};
const initialActiveRelatedCodes = {{ (process_cost_active_related_codes or []) | tojson }};
const initialQuickLinkCodes = {{ (process_cost_quick_link_codes or []) | tojson }};
const selectedCode = typeof pageState.selectedCode === "string" ? pageState.selectedCode : "";
const selectedProject = pageState.selectedProject && typeof pageState.selectedProject === "object"
? pageState.selectedProject
: {};
const initialRelatedCodes = Array.isArray(pageState.relatedCodes) ? pageState.relatedCodes : [];
const initialActiveRelatedCodes = Array.isArray(pageState.activeRelatedCodes) ? pageState.activeRelatedCodes : [];
const initialQuickLinkCodes = Array.isArray(pageState.quickLinkCodes) ? pageState.quickLinkCodes : [];
const searchInput = document.getElementById("projectSearchInput");
const list = document.getElementById("projectList");
const projectListEmpty = document.getElementById("projectListEmpty");
let projectListEmpty = document.getElementById("projectListEmpty");
const typeFilter = document.getElementById("projectTypeFilter");
const quickLinksContainer = document.getElementById("pcQuickLinks");
const selectedProjectBookmark = document.getElementById("selectedProjectBookmark");
@@ -1093,18 +1101,21 @@
const processModalNote = document.getElementById("processFlowModalNote");
const processModalClose = document.getElementById("processFlowModalClose");
const processButtons = document.querySelectorAll("[data-process-modal]");
const includeRelatedEnabled = {{ 1 if process_cost_include_related else 0 }};
const selectedStartYear = pageState.selectedStartYear;
const selectedEndYear = pageState.selectedEndYear;
const selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
const includeRelatedEnabled = Boolean(pageState.includeRelatedEnabled);
let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : [];
let activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
function buildProcessCostUrl(code, options = {}) {
const params = new URLSearchParams();
params.set("source", {{ process_cost_source | tojson }});
if ({{ process_cost_selected_start_year | tojson }}) {
params.set("start_year", {{ process_cost_selected_start_year | tojson }});
params.set("source", selectedSource);
if (selectedStartYear) {
params.set("start_year", String(selectedStartYear));
}
if ({{ process_cost_selected_end_year | tojson }}) {
params.set("end_year", {{ process_cost_selected_end_year | tojson }});
if (selectedEndYear) {
params.set("end_year", String(selectedEndYear));
}
if (code) {
params.set("code", code);
@@ -1128,6 +1139,44 @@
.filter((item) => item && item.support_dept_code);
}
function getFilteredProjectItems() {
const keyword = (searchInput?.value || "").trim().toLowerCase();
const typeValue = (typeFilter?.value || "").trim().toLowerCase();
const bookmarkedSet = new Set(bookmarkedCodes);
return processCostProjects
.filter((item) => item && item.support_dept_code)
.filter((item) => {
const text = `${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase();
const kind = String(item.project_kind_code || "").toLowerCase();
const matchesKeyword = !keyword || text.includes(keyword);
const matchesType = !typeValue || kind === typeValue;
return matchesKeyword && matchesType && !bookmarkedSet.has(item.support_dept_code);
});
}
function renderProjectListItems() {
if (!list) return 0;
const items = getFilteredProjectItems();
list.innerHTML = `${items.map((item) => `
<a
href="${buildProcessCostUrl(item.support_dept_code)}"
class="pc-project-item ${selectedCode === item.support_dept_code ? "active" : ""}"
data-project-search="${escapeHtml(`${item.support_dept_code} ${item.support_dept_name || ""} ${item.client_name || ""}`.toLowerCase())}"
data-project-kind="${escapeHtml(String(item.project_kind_code || "").toLowerCase())}"
title="${escapeHtml(`${item.support_dept_code} ${item.support_dept_name || ""}`.trim())}"
>
<div class="pc-project-head">
<span class="pc-project-code">${escapeHtml(item.support_dept_code)}</span>
<span class="pc-type-pill">${escapeHtml(item.project_kind_label || "")}</span>
</div>
<div class="pc-project-name">${escapeHtml(item.support_dept_name || "")}</div>
${item.client_name ? `<div class="pc-project-name">${escapeHtml(item.client_name)}</div>` : ""}
</a>
`).join("")}<div class="pc-note" id="projectListEmpty" ${items.length ? "hidden" : ""} style="padding: 12px;">검색 결과가 없습니다.</div>`;
projectListEmpty = document.getElementById("projectListEmpty");
return items.length;
}
async function persistBookmarkedCodes(nextCodes) {
const response = await fetch("/process-cost/quick-links", {
method: "POST",
@@ -1171,10 +1220,10 @@
return;
}
quickLinksContainer.innerHTML = items.map((item) => `
<div class="pc-quick-link" title="${item.support_dept_code} ${item.support_dept_name || ""}">
<div class="pc-quick-link" title="${escapeHtml(item.support_dept_code)} ${escapeHtml(item.support_dept_name || "")}">
<a href="${buildProcessCostUrl(item.support_dept_code)}" class="pc-quick-link-main">
<strong>${item.support_dept_code}</strong>
<span>${item.support_dept_name || ""}</span>
<strong>${escapeHtml(item.support_dept_code)}</strong>
<span>${escapeHtml(item.support_dept_name || "")}</span>
</a>
<button type="button" class="pc-bookmark-button is-active" data-bookmark-toggle="${item.support_dept_code}" aria-label="바로가기 해제" title="바로가기 해제">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -1205,42 +1254,16 @@
syncSelectedProjectCard();
}
function getVisibleProjectItems() {
return [...list.querySelectorAll("[data-project-search]")].filter((item) => item.style.display !== "none");
}
function filterProjectList() {
if (!searchInput || !list) return;
const keyword = (searchInput.value || "").trim().toLowerCase();
const typeValue = (typeFilter?.value || "").trim().toLowerCase();
const bookmarkedSet = new Set(bookmarkedCodes);
let visibleCount = 0;
list.querySelectorAll("[data-project-search]").forEach((item) => {
const text = (item.dataset.projectSearch || "").toLowerCase();
const kind = (item.dataset.projectKind || "").toLowerCase();
const matchesKeyword = !keyword || text.includes(keyword);
const matchesType = !typeValue || kind === typeValue;
const codeNode = item.querySelector(".pc-project-code");
const code = (codeNode?.textContent || "").trim();
const isVisible = matchesKeyword && matchesType && !bookmarkedSet.has(code);
item.hidden = !isVisible;
if (isVisible) {
visibleCount += 1;
}
});
if (projectListEmpty) {
projectListEmpty.hidden = visibleCount !== 0;
}
return visibleCount;
return renderProjectListItems();
}
function openProjectList() {
if (!list) return;
const visibleCount = filterProjectList();
const visibleItems = getVisibleProjectItems();
list.hidden = false;
if (projectListEmpty) {
projectListEmpty.hidden = (visibleCount || visibleItems.length) !== 0;
projectListEmpty.hidden = visibleCount !== 0;
}
}
@@ -1375,7 +1398,7 @@
<a
href="${buildProcessCostUrl(code, { activeRelatedCodes: openActiveCodes, includeRelated: includeRelatedEnabled })}"
class="pc-chip-link"
>${code}</a>
>${escapeHtml(code)}</a>
<button
type="button"
class="pc-related-include ${isActive ? "is-active" : ""}"
@@ -1385,7 +1408,6 @@
title="${isActive ? "합산 제외" : "합산 포함"}"
${includeRelatedEnabled ? "" : "disabled"}
>${isActive ? "" : "+"}</button>
<button type="button" class="pc-related-remove" data-related-remove="${code}">x</button>
</span>
`;
}).join("");
@@ -1406,16 +1428,6 @@
});
});
});
relatedChipRow.querySelectorAll("[data-related-remove]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedRemove || "";
if (code) {
relatedSet.delete(code);
activeRelatedCodes = activeRelatedCodes.filter((item) => item !== code);
renderRelatedChips();
}
});
});
}
function renderRelatedSuggestions(keyword) {
@@ -1438,20 +1450,13 @@
}
relatedSuggestList.innerHTML = rows.map((item) => `
<button type="button" class="pc-related-item" data-related-pick="${item.support_dept_code}">
${item.support_dept_code} ${item.support_dept_name || ""}
${escapeHtml(item.support_dept_code)} ${escapeHtml(item.support_dept_name || "")}
</button>
`).join("");
relatedSuggestList.classList.add("active");
relatedSuggestList.querySelectorAll("[data-related-pick]").forEach((button) => {
button.addEventListener("click", () => {
const code = button.dataset.relatedPick || "";
if (code) {
relatedSet.add(code);
if (relatedSearchInput) relatedSearchInput.value = "";
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
renderRelatedChips();
}
window.alert("연계 프로젝트 링크는 DB 자동 규칙으로만 계산됩니다.");
});
});
}
@@ -1463,47 +1468,26 @@
relatedSearchInput.addEventListener("focus", () => {
renderRelatedSuggestions(relatedSearchInput.value || "");
});
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".pc-related-form")) return;
relatedSuggestList?.classList.remove("active");
});
}
if (relatedResetButton) {
relatedResetButton.addEventListener("click", () => {
relatedSet.clear();
for (const code of initialRelatedCodes || []) {
relatedSet.add(code);
}
activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
if (relatedSearchInput) relatedSearchInput.value = "";
if (relatedSuggestList) {
relatedSuggestList.classList.remove("active");
relatedSuggestList.innerHTML = "";
}
renderRelatedChips();
window.location.href = buildProcessCostUrl(selectedCode, {
includeRelated: includeRelatedEnabled,
activeRelatedCodes: Array.isArray(initialRelatedCodes) ? [...initialRelatedCodes] : [],
});
});
}
if (relatedSaveButton) {
relatedSaveButton.addEventListener("click", async () => {
if (!selectedCode) return;
relatedSaveButton.disabled = true;
try {
const response = await fetch("/projects/related-links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_code: selectedCode,
related_codes: [...relatedSet],
}),
});
const payload = await response.json();
if (!response.ok || payload.error) {
throw new Error(payload.error || "연계 프로젝트 저장에 실패했습니다.");
}
window.location.reload();
} catch (error) {
alert(error.message || "연계 프로젝트 저장에 실패했습니다.");
} finally {
relatedSaveButton.disabled = false;
}
relatedSaveButton.addEventListener("click", () => {
window.alert("연계 프로젝트 링크는 DB 자동 규칙으로만 계산됩니다.");
});
}
@@ -1546,7 +1530,7 @@
syncYearRangeOptions();
}
const monthlyRows = {{ (process_cost_detail.monthly_rows or []) | tojson }};
const monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : [];
const svg = document.getElementById("processMonthlyChart");
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
return;
@@ -1571,7 +1555,7 @@
let labels = "";
monthlyRows.forEach((row, i) => {
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${(row.month_label || '').slice(2)}</text>`;
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${escapeHtml((row.month_label || "").slice(2))}</text>`;
});
svg.innerHTML = `