Update project pages and add DB browser
This commit is contained in:
+349
-66
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user