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
+349 -66
View File
@@ -49,6 +49,11 @@
min-width: 156px;
}
.existing-rate-option {
background: #d9dde3;
color: var(--ink);
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
@@ -314,7 +319,7 @@
.project-search-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px 236px;
grid-template-columns: minmax(0, 1fr) 44px 180px 236px;
gap: 12px;
align-items: center;
min-height: var(--toolbar-row-height);
@@ -614,8 +619,7 @@
flex: 0 0 auto;
}
.related-project-tag .related-project-toggle,
.related-project-tag [data-remove-related] {
.related-project-tag .related-project-toggle {
width: 12px;
height: 12px;
min-width: 12px;
@@ -637,8 +641,7 @@
justify-content: center;
}
.related-project-tag .related-project-toggle:hover,
.related-project-tag [data-remove-related]:hover {
.related-project-tag .related-project-toggle:hover {
transform: none;
background: transparent;
color: var(--ink);
@@ -648,6 +651,44 @@
font-weight: 900;
}
.related-project-remove {
border: none;
padding: 0;
margin: 0;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: 0 0 auto;
width: 14px;
height: 14px;
min-width: 14px;
min-height: 14px;
max-width: 14px;
max-height: 14px;
border-radius: 999px;
aspect-ratio: 1 / 1;
background: rgba(15, 23, 42, 0.08);
box-shadow: none !important;
transform: none !important;
line-height: 1;
}
.related-project-remove:hover {
background: rgba(15, 23, 42, 0.14);
color: var(--ink);
transform: none;
}
.related-project-remove svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 2;
fill: none;
}
.analysis-inline-note {
width: 100%;
min-height: 40px;
@@ -2093,19 +2134,19 @@
}
.exec-labor-table col:nth-child(1) {
width: 18%;
width: 24%;
}
.exec-labor-table col:nth-child(2) {
width: 16%;
width: 18%;
}
.exec-labor-table col:nth-child(3) {
width: 30%;
width: 28%;
}
.exec-labor-table col:nth-child(4) {
width: 26%;
width: 20%;
}
.exec-labor-table col:nth-child(5) {
@@ -2113,16 +2154,27 @@
}
.exec-labor-table .exec-labor-grade {
max-width: 90px;
max-width: none;
min-width: 0;
width: 100%;
color: var(--ink);
background: #fff;
}
.exec-labor-table .exec-labor-hours {
max-width: 88px;
max-width: none;
min-width: 0;
width: 100%;
text-align: right;
}
.exec-labor-table .exec-labor-rate-year {
max-width: 104px;
max-width: none;
min-width: 0;
width: 100%;
padding-right: 24px;
color: var(--ink);
background: #fff;
}
.section-columns {
@@ -2610,14 +2662,6 @@
</svg>
<span class="sr-only">페이지 입력사항 저장</span>
</button>
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary" title="연관 프로젝트 검색" aria-label="연관 프로젝트 검색">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.8 13.2l2.4-2.4"></path>
<path d="M7.5 14.7l-1.6 1.6a3 3 0 1 1-4.2-4.2l3-3a3 3 0 0 1 4.2 0"></path>
<path d="M16.5 9.3l1.6-1.6a3 3 0 1 1 4.2 4.2l-3 3a3 3 0 0 1-4.2 0"></path>
</svg>
<span class="sr-only">연관 프로젝트 검색</span>
</button>
<button type="button" id="toggleProjectAnalysis" class="button-icon button-secondary toggle-analysis-button" title="프로젝트 상세 열기/숨기기" aria-label="프로젝트 상세 열기/숨기기">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 9l6 6 6-6"></path>
@@ -2641,6 +2685,15 @@
<div class="project-search-empty" id="projectExplorerEmpty"></div>
</div>
</div>
<button type="button" id="openRelatedProjectToolbar" class="button-icon button-secondary related-trigger-button" title="연관 프로젝트 추가" aria-label="연관 프로젝트 추가">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1"></path>
<path d="M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1.1-1.1"></path>
<path d="M12 8v8"></path>
<path d="M8 12h8"></path>
</svg>
<span class="sr-only">연관 프로젝트 추가</span>
</button>
<div class="field">
<select id="projectCostYearFilter" aria-label="연도 필터">
<option value="">전체 연도</option>
@@ -2969,10 +3022,10 @@
<div class="form-section-head">
<h3>실행예산계획</h3>
<div class="form-section-actions">
<div class="table-total">합계: <span id="execBudgetTotalDisplay">0</span></div>
<button type="button" class="button-link button-secondary mini-button" data-save-scope-button="exec_budget">실행예산 저장</button>
</div>
</div>
<div class="table-total">실행예산 합계: <span id="execBudgetTotalDisplay">0</span></div>
<div class="section-columns">
<section class="mini-sector">
<div class="table-tools">
@@ -3214,7 +3267,7 @@
<label for="expected_as_rate">예상 A/S비 비율</label>
<select id="expected_as_rate" name="expected_as_rate">
{% for option in expected_as_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
<option value="{{ option.value }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% if project_edit.expected_as_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
@@ -3226,7 +3279,7 @@
<label for="expected_sga_rate">예상 판관비 비율</label>
<select id="expected_sga_rate" name="expected_sga_rate">
{% for option in expected_sga_rate_options %}
<option value="{{ option.value }}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
<option value="{{ option.value }}" class="{% if option.is_existing_value %}existing-rate-option{% endif %}" {% if project_edit.expected_sga_rate|string == option.value|string %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
@@ -3656,6 +3709,8 @@
const contractAmountInput = document.getElementById("contract_amount");
const expectedAsRateInput = document.getElementById("expected_as_rate");
const expectedSgaRateInput = document.getElementById("expected_sga_rate");
const defaultExpectedAsRateValues = new Set(["0", "2", "5", "10"]);
const defaultExpectedSgaRateValues = new Set(["13", "15", "20", "25"]);
const editRevisionInput = projectStatusForm?.querySelector('input[name="edit_revision"]');
const collectionRows = document.getElementById("collectionRows");
const taskPlanDepartmentRows = document.getElementById("taskPlanDepartmentRows");
@@ -4125,6 +4180,54 @@
return Number.isFinite(numeric) ? numeric : 0;
}
function roundPercentageRate(value) {
const numeric = parseAmount(value);
return Number.isFinite(numeric) ? Math.round(numeric) : 0;
}
function normalizePercentageSelectValue(input) {
if (!input) return "";
const rawValue = String(input.value ?? "").trim();
if (!rawValue) return "";
const roundedValue = String(roundPercentageRate(rawValue));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
return roundedValue;
}
function isDefaultPercentageRateValue(input, value) {
const normalizedValue = String(value ?? "").trim();
if (input === expectedAsRateInput) {
return defaultExpectedAsRateValues.has(normalizedValue);
}
if (input === expectedSgaRateInput) {
return defaultExpectedSgaRateValues.has(normalizedValue);
}
return false;
}
function setRoundedPercentageSelectValue(input, value) {
if (!input || value === null || value === undefined || value === "") {
if (input) input.value = "";
return;
}
const roundedValue = String(roundPercentageRate(value));
if (![...input.options].some((option) => option.value === roundedValue)) {
const option = new Option(`${roundedValue}%`, roundedValue);
if (!isDefaultPercentageRateValue(input, roundedValue)) {
option.classList.add("existing-rate-option");
}
input.add(option);
}
input.value = roundedValue;
}
function digitsOnly(value) {
return String(value ?? "").replace(/\D/g, "");
}
@@ -4276,9 +4379,59 @@
}
}
function ceilToHundreds(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.ceil(amount / 100) * 100;
}
function getNumericYearRate(yearRates, grade) {
return Number(yearRates?.[grade] || 0);
}
function hasPositiveYearRates(yearRates, grades) {
return grades.every((grade) => getNumericYearRate(yearRates, grade) > 0);
}
function getDerivedLaborRate(yearRates, grade) {
if (grade === "수석") {
const directorRate = getNumericYearRate(yearRates, "이사");
return directorRate > 0 ? directorRate : 0;
}
if (grade === "책임") {
if (!hasPositiveYearRates(yearRates, ["부장", "차장"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "부장") * 5) + (getNumericYearRate(yearRates, "차장") * 2)) / 7);
}
if (grade === "선임") {
if (!hasPositiveYearRates(yearRates, ["차장", "과장", "대리"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "차장") * 2) + (getNumericYearRate(yearRates, "과장") * 3) + getNumericYearRate(yearRates, "대리")) / 6);
}
if (grade === "연구원") {
if (!hasPositiveYearRates(yearRates, ["대리", "사원"])) return 0;
return ceilToHundreds(((getNumericYearRate(yearRates, "대리") * 2) + (getNumericYearRate(yearRates, "사원") * 3)) / 5);
}
return 0;
}
function getResolvedLaborRatesForYear(year = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const storedYearRates = currentLaborRates?.[yearKey] || {};
const resolvedYearRates = {};
laborGradeOptions.forEach((grade) => {
resolvedYearRates[grade] = getNumericYearRate(storedYearRates, grade);
});
["수석", "책임", "선임", "연구원"].forEach((grade) => {
const derived = getDerivedLaborRate(storedYearRates, grade);
if (derived > 0) {
resolvedYearRates[grade] = derived;
}
});
return resolvedYearRates;
}
function getLaborRate(grade, year = "") {
const yearKey = String(year || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[yearKey] || {};
const yearRates = getResolvedLaborRatesForYear(yearKey);
const direct = Number(yearRates?.[grade] || 0);
if (direct) return direct;
const gradeIndex = laborGradeOptions.indexOf(grade);
@@ -4368,17 +4521,17 @@
updateCollectionContractCells(contractAmount, collectionTotal);
document.getElementById("progress_rate_display").textContent = `${progressRate.toFixed(1)}%`;
const expectedAsCost = contractAmount * parseAmount(expectedAsRateInput?.value) / 100;
const expectedSgaBudget = contractAmount * parseAmount(expectedSgaRateInput?.value) / 100;
const expectedAsRate = roundPercentageRate(expectedAsRateInput?.value);
const expectedSgaRate = roundPercentageRate(expectedSgaRateInput?.value);
const expectedAsCost = contractAmount * expectedAsRate / 100;
const expectedSgaBudget = contractAmount * expectedSgaRate / 100;
const expectedAsCostField = document.getElementById("expected_as_cost_display");
const expectedSgaBudgetField = document.getElementById("expected_sga_budget_display");
if (expectedAsCostField && document.activeElement !== expectedAsCostField) {
const keepManual = parseAmount(expectedAsCostField.value);
expectedAsCostField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedAsCost ? formatAmountInputValue(expectedAsCost) : "");
expectedAsCostField.value = expectedAsCost ? formatAmountInputValue(expectedAsCost) : "";
}
if (expectedSgaBudgetField && document.activeElement !== expectedSgaBudgetField) {
const keepManual = parseAmount(expectedSgaBudgetField.value);
expectedSgaBudgetField.value = keepManual ? formatAmountInputValue(keepManual) : (expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "");
expectedSgaBudgetField.value = expectedSgaBudget ? formatAmountInputValue(expectedSgaBudget) : "";
}
syncExpectedAllocationsToActualInputs();
@@ -4965,14 +5118,16 @@
if (!laborRateRows) return;
const selectedRateYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
const yearRates = currentLaborRates?.[selectedRateYear] || {};
const resolvedYearRates = getResolvedLaborRatesForYear(selectedRateYear);
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(yearRates?.[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>
</tr>
`).join("");
bindCollectionFieldBehaviors(laborRateRows);
laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => {
if (input.hasAttribute("readonly")) return;
input.addEventListener("input", () => {
const grade = input.dataset.grade || "";
const targetYear = String(laborRateYearSelect?.value || getDefaultLaborRateYear());
@@ -5006,9 +5161,12 @@
const nextYearRates = { ...(currentLaborRates?.[targetYear] || {}) };
laborRateRows?.querySelectorAll(".labor-rate-input").forEach((input) => {
const grade = String(input.dataset.grade || "").trim();
if (!grade) return;
if (!grade || input.hasAttribute("readonly")) return;
nextYearRates[grade] = parseAmount(input.value);
});
["수석", "책임", "선임", "연구원"].forEach((grade) => {
nextYearRates[grade] = getDerivedLaborRate(nextYearRates, grade);
});
currentLaborRates[targetYear] = nextYearRates;
writeLaborRates(currentLaborRates);
recalculateExecLaborAmounts();
@@ -5171,8 +5329,8 @@
projectImportReviewTag.textContent = normalized.review_tag || "";
projectImportReviewNote.textContent = normalized.review_note || "";
}
expectedAsRateInput.value = normalized.expected_as_rate === null || normalized.expected_as_rate === undefined || normalized.expected_as_rate === "" ? "" : String(normalized.expected_as_rate);
expectedSgaRateInput.value = normalized.expected_sga_rate === null || normalized.expected_sga_rate === undefined || normalized.expected_sga_rate === "" ? "" : String(normalized.expected_sga_rate);
setRoundedPercentageSelectValue(expectedAsRateInput, normalized.expected_as_rate);
setRoundedPercentageSelectValue(expectedSgaRateInput, normalized.expected_sga_rate);
if (editRevisionInput) {
editRevisionInput.value = normalized.updated_at || "";
}
@@ -5505,8 +5663,14 @@
});
contractAmountInput?.addEventListener("input", updateComputedFields);
expectedAsRateInput?.addEventListener("change", updateComputedFields);
expectedSgaRateInput?.addEventListener("change", updateComputedFields);
expectedAsRateInput?.addEventListener("change", () => {
normalizePercentageSelectValue(expectedAsRateInput);
updateComputedFields();
});
expectedSgaRateInput?.addEventListener("change", () => {
normalizePercentageSelectValue(expectedSgaRateInput);
updateComputedFields();
});
document.getElementById("expected_as_cost_display")?.addEventListener("input", (event) => {
event.target.value = formatAmountInputValue(event.target.value);
syncExpectedAllocationsToActualInputs();
@@ -5822,6 +5986,72 @@
return mergedItem;
}
function getSharedInputClusterCodes(item) {
if (!Array.isArray(item?.shared_input_cluster_codes)) return [];
return item.shared_input_cluster_codes
.map((value) => String(value || "").trim())
.filter(Boolean);
}
function shouldIncludeSharedInputs(item, includedCodeSet) {
const ownerCode = String(item?.shared_input_owner_code || "").trim();
if (!ownerCode || String(item?.support_dept_code || "").trim() !== ownerCode) {
return true;
}
const clusterCodes = getSharedInputClusterCodes(item);
if (!clusterCodes.length) return true;
return clusterCodes.every((code) => includedCodeSet.has(code));
}
function stripSharedClusterInputs(item) {
if (!item) return item;
return {
...item,
task_plan_department_budget: 0,
task_plan_outsource_budget: 0,
task_plan_joint_operating_cost: 0,
exec_budget_labor_by_grade: 0,
exec_budget_outsource: 0,
exec_budget_cost_plan: 0,
expected_as_cost: 0,
expected_sga_budget: 0,
item_investment: 0,
task_plan_entries: [],
exec_budget_entries: [],
actual_input_entries: [],
planned_task_total: 0,
exec_budget_total: 0,
};
}
function normalizeAnalysisItemForSelection(item, includedCodeSet) {
if (!item) return item;
if (shouldIncludeSharedInputs(item, includedCodeSet)) {
return item;
}
return stripSharedClusterInputs(item);
}
function getSuppressedSharedOwnerCodes(includedCodeSet) {
const normalizedIncludedCodeSet = new Set(
[...(includedCodeSet || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const suppressedOwnerCodes = new Set();
normalizedIncludedCodeSet.forEach((code) => {
const sourceItem = getAnalysisItem(code) || projectStatusMap[code] || null;
if (!sourceItem) return;
const ownerCode = String(sourceItem?.shared_input_owner_code || "").trim();
const clusterCodes = getSharedInputClusterCodes(sourceItem);
if (!ownerCode || !clusterCodes.length) return;
if (!clusterCodes.every((clusterCode) => normalizedIncludedCodeSet.has(clusterCode))) {
suppressedOwnerCodes.add(ownerCode);
}
});
return suppressedOwnerCodes;
}
function getProjectContractState(item) {
const code = item?.support_dept_code || "";
const contractAmount = Number(item?.contract_amount || projectStatusMap[code]?.contract_amount || 0);
@@ -6576,6 +6806,12 @@
return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
}
function getAggregateContractAmount(items) {
return (items || []).reduce((sum, item) => {
return sum + Math.max(0, Number(item?.contract_amount || 0));
}, 0);
}
function prefixProjectEntries(item, entries, fallbackLabel) {
return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({
...entry,
@@ -6598,15 +6834,26 @@
return mergeLabeledAmountEntries(merged);
}
function aggregateAnalysisItem(baseItem, relatedItems = []) {
function aggregateAnalysisItem(baseItem, relatedItems = [], options = {}) {
if (!baseItem) return null;
const items = [baseItem, ...relatedItems].filter(Boolean);
const suppressedSharedOwnerCodes = new Set(
[...(options?.suppressedSharedOwnerCodes || [])]
.map((value) => String(value || "").trim())
.filter(Boolean),
);
const yearValues = items
.flatMap((item) => [Number(item.min_year || item.year || 0), Number(item.max_year || item.year || 0)])
.filter(Boolean);
const minYear = yearValues.length ? Math.min(...yearValues) : 0;
const maxYear = yearValues.length ? Math.max(...yearValues) : 0;
const aggregated = {
...baseItem,
_aggregateCodes: items.map((item) => item.support_dept_code),
_relatedItems: relatedItems,
min_year: Math.min(...items.map((item) => Number(item.min_year || item.year || 0)).filter(Boolean)),
max_year: Math.max(...items.map((item) => Number(item.max_year || item.year || 0)).filter(Boolean)),
_suppressedSharedOwnerCodes: [...suppressedSharedOwnerCodes],
min_year: minYear,
max_year: maxYear,
latest_year: baseItem.latest_year,
latest_month: baseItem.latest_month,
revenue_amount: 0,
@@ -6642,7 +6889,6 @@
"total_revenue",
"total_cost",
"total_sga",
"contract_amount",
"collection_amount",
"planned_task_total",
"exec_budget_total",
@@ -6671,8 +6917,10 @@
aggregated.latest_month = item.latest_month;
}
});
aggregated.contract_amount = getAggregateContractAmount(items);
aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0);
aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0);
aggregated.year_range = formatYearRange(aggregated);
return aggregated;
}
@@ -6697,10 +6945,22 @@
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
}
function formatCollectionComparisonDiff(planned, actual) {
const diff = Number(actual || 0) - Number(planned || 0);
return `<span class="${differenceClass(diff)}">${formatSignedAmount(diff)}</span>`;
}
function buildComparisonDetails(item) {
const aggregateCodes = Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
? item._aggregateCodes
: [item.support_dept_code];
const suppressedSharedOwnerCodes = new Set(
Array.isArray(item?._suppressedSharedOwnerCodes)
? item._suppressedSharedOwnerCodes.map((value) => String(value || "").trim()).filter(Boolean)
: [],
);
const aggregateCodes = (
Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length
? item._aggregateCodes
: [item.support_dept_code]
).filter((code) => !suppressedSharedOwnerCodes.has(String(code || "").trim()));
const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력");
const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue");
const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비");
@@ -7327,7 +7587,12 @@
</span>
<span class="related-project-actions">
<button type="button" class="related-project-toggle" data-toggle-related="${escapeHtml(item.support_dept_code)}" title="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}" aria-label="${isInactive ? "상세 반영 다시 켜기" : "상세 반영 끄기"}">${isInactive ? "+" : "-"}</button>
<button type="button" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">×</button>
<button type="button" class="related-project-remove" data-remove-related="${escapeHtml(item.support_dept_code)}" title="연관 프로젝트 제거" aria-label="연관 프로젝트 제거">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 4.5l7 7"></path>
<path d="M11.5 4.5l-7 7"></path>
</svg>
</button>
</span>
</span>
`;
@@ -7365,7 +7630,7 @@
</div>
<div class="analysis-kpi">
<span>영업수지</span>
<strong>${formatDisplayAmount(operatingBalance)}</strong>
<strong class="${differenceClass(operatingBalance)}">${formatDisplayAmount(operatingBalance)}</strong>
</div>
<div class="analysis-kpi">
<span>과업수행계획비용</span>
@@ -7428,7 +7693,7 @@
</td>
<td>${formatComparisonCell(planned)}</td>
<td>${formatComparisonCell(actual)}</td>
<td>${formatComparisonDiff(planned, actual)}</td>
<td>${key === "collection" ? formatCollectionComparisonDiff(planned, actual) : formatComparisonDiff(planned, actual)}</td>
<td>
<textarea
class="comparison-note-input"
@@ -7929,16 +8194,6 @@
});
};
mergeSource(persistedProjectRelatedLinks);
mergeSource(persistedProjectPageState?.related_project_selections);
try {
const raw = window.localStorage.getItem(RELATED_PROJECT_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
mergeSource(parsed);
}
} catch (error) {
console.warn("Failed to load related project selections", error);
}
return merged;
}
@@ -7946,15 +8201,24 @@
window.__projectRelatedSelections = relatedProjectSelections;
}
function replaceRelatedProjectSelections(source) {
if (!source || typeof source !== "object") return;
relatedProjectSelections.clear();
Object.entries(source).forEach(([code, values]) => {
const normalizedCode = String(code || "").trim();
if (!normalizedCode || !Array.isArray(values)) return;
const normalizedValues = values
.map((value) => String(value || "").trim())
.filter(Boolean);
if (normalizedValues.length) {
relatedProjectSelections.set(normalizedCode, new Set(normalizedValues));
}
});
syncGlobalRelatedProjectSelections();
}
function persistRelatedProjectSelections() {
try {
const payload = Object.fromEntries(
[...relatedProjectSelections.entries()].map(([code, values]) => [code, [...values]]),
);
window.localStorage.setItem(RELATED_PROJECT_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("Failed to persist related project selections", error);
}
return;
}
async function saveRelatedProjectSelectionsForCode(baseCode) {
@@ -7980,6 +8244,7 @@
if (result?.error) {
throw new Error(result.error);
}
replaceRelatedProjectSelections(result.related_project_links);
bumpUncontractedDashboardRevision();
}
@@ -8480,6 +8745,7 @@
} catch (error) {
console.error(error);
window.alert("연관 프로젝트를 DB에 저장하지 못했습니다.");
return;
}
bumpUncontractedDashboardRevision();
closeRelatedProjectPicker();
@@ -8502,6 +8768,7 @@
} catch (error) {
console.error(error);
window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다.");
return;
}
bumpUncontractedDashboardRevision();
renderAnalysis(getAnalysisItem(selectedCode));
@@ -8683,9 +8950,21 @@
scheduleStandaloneUncontractedDashboardRender();
return;
}
const baseCode = String(item.support_dept_code || "").trim();
const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true });
const relatedItems = getRelatedItems(item.support_dept_code);
const aggregateItem = aggregateAnalysisItem(item, relatedItems);
const includedCodeSet = new Set([
baseCode,
...relatedItems.map((relatedItem) => String(relatedItem?.support_dept_code || "").trim()).filter(Boolean),
]);
const suppressedSharedOwnerCodes = getSuppressedSharedOwnerCodes(includedCodeSet);
const normalizedBaseItem = normalizeAnalysisItemForSelection(item, includedCodeSet);
const normalizedRelatedItems = relatedItems.map((relatedItem) => {
return normalizeAnalysisItemForSelection(relatedItem, includedCodeSet);
});
const aggregateItem = aggregateAnalysisItem(normalizedBaseItem, normalizedRelatedItems, {
suppressedSharedOwnerCodes,
});
relatedBarBox.innerHTML = renderRelatedProjectBar(item, allRelatedItems);
const detailNoteInput = relatedBarBox.querySelector("[data-analysis-detail-note]");
if (detailNoteInput) {
@@ -8726,7 +9005,11 @@
});
});
relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => {
button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || ""));
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
await removeRelatedProject(button.dataset.removeRelated || "");
});
});
relatedBarBox.querySelectorAll("[data-open-related-project]").forEach((button) => {
button.addEventListener("click", (event) => {