243 lines
11 KiB
JavaScript
243 lines
11 KiB
JavaScript
const EstSchedule = (() => {
|
|
function day(value) {
|
|
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
throw Error("공정 날짜는 YYYY-MM-DD 형식입니다.");
|
|
const time = Date.parse(value + "T00:00:00Z");
|
|
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value)
|
|
throw Error("공정 날짜 오류");
|
|
return time / 86400000;
|
|
}
|
|
function validate(project) {
|
|
if (project.schedule == null) return;
|
|
if (!Array.isArray(project.schedule) || project.schedule.length > 10000)
|
|
throw Error("공정표 형식/행 수 오류");
|
|
const ids = new Set();
|
|
for (const row of project.schedule) {
|
|
if (!row || typeof row.id !== "string" || !row.id || ids.has(row.id))
|
|
throw Error("공정 항목 ID 오류");
|
|
ids.add(row.id);
|
|
if (
|
|
row.SourceKey != null &&
|
|
(typeof row.SourceKey !== "string" || row.SourceKey.length > 1000)
|
|
)
|
|
throw Error("공정 내역 연결 오류");
|
|
for (const key of ["Code", "Name", "start", "end"])
|
|
if (typeof row[key] !== "string" || row[key].length > 1000) throw Error("공정 문자열 오류");
|
|
for (const key of ["weight", "progress"])
|
|
if (
|
|
typeof row[key] !== "number" ||
|
|
!Number.isFinite(row[key]) ||
|
|
row[key] < 0 ||
|
|
row[key] > Number.MAX_SAFE_INTEGER
|
|
)
|
|
throw Error("공정 수치 오류");
|
|
if (row.progress > 100) throw Error("공정 진도는 0~100%입니다.");
|
|
if (row.start) day(row.start);
|
|
if (row.end) day(row.end);
|
|
if (row.start && row.end && day(row.start) > day(row.end))
|
|
throw Error("공정 종료일이 시작일보다 빠릅니다.");
|
|
}
|
|
}
|
|
function calculate(project) {
|
|
validate(project);
|
|
const rows = project.schedule || [],
|
|
dated = rows.filter((r) => r.start && r.end),
|
|
first = dated.length ? Math.min(...dated.map((r) => day(r.start))) : 0,
|
|
last = dated.length ? Math.max(...dated.map((r) => day(r.end))) : first,
|
|
span = last - first + 1,
|
|
weight = rows.reduce((sum, r) => sum + r.weight, 0);
|
|
return {
|
|
rows: rows.map((r) => ({
|
|
...r,
|
|
left: r.start && r.end ? ((day(r.start) - first) / span) * 100 : 0,
|
|
width: r.start && r.end ? ((day(r.end) - day(r.start) + 1) / span) * 100 : 0,
|
|
})),
|
|
weight,
|
|
progress: weight ? rows.reduce((sum, r) => sum + r.weight * r.progress, 0) / weight : 0,
|
|
start: dated.length ? new Date(first * 86400000).toISOString().slice(0, 10) : "",
|
|
end: dated.length ? new Date(last * 86400000).toISOString().slice(0, 10) : "",
|
|
};
|
|
}
|
|
function estimateRows(project) {
|
|
const rows = new Map(),
|
|
codes = new Map();
|
|
for (const row of project.tables.EstMainDat) {
|
|
if (
|
|
Number(row.Level) > 0 ||
|
|
Number(row.CalcIndex) >= 2 ||
|
|
[54, 55, 58, 59].includes(Number(row.LinkIndex))
|
|
)
|
|
continue;
|
|
const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : "";
|
|
if (!key) continue;
|
|
if (rows.has(key)) throw Error(`내역 행 식별자 중복: ${key}`);
|
|
rows.set(key, row);
|
|
if (row.Code) {
|
|
const matches = codes.get(row.Code) || [];
|
|
matches.push(key);
|
|
codes.set(row.Code, matches);
|
|
}
|
|
}
|
|
function sourceKey(row) {
|
|
if (row.SourceKey) return rows.has(row.SourceKey) ? row.SourceKey : "";
|
|
const matches = codes.get(row.Code) || [];
|
|
if (matches.length > 1) throw Error(`기존 공정의 내역코드 중복: ${row.Code}`);
|
|
return matches[0] || "";
|
|
}
|
|
return { rows, sourceKey };
|
|
}
|
|
function fromEstimate(project, makeId) {
|
|
const { rows, sourceKey } = estimateRows(project),
|
|
existing = new Set((project.schedule || []).map(sourceKey).filter(Boolean));
|
|
return [...rows.entries()]
|
|
.filter(([key]) => !existing.has(key))
|
|
.map(([key, row]) => ({
|
|
id: makeId(),
|
|
SourceKey: key,
|
|
Code: String(row.Code || ""),
|
|
Name: String(row.Name || ""),
|
|
start: "",
|
|
end: "",
|
|
weight: Math.max(0, Number(row.TC) || 0),
|
|
progress: 0,
|
|
}));
|
|
}
|
|
function refreshWeights(project) {
|
|
const { rows, sourceKey } = estimateRows(project);
|
|
return (project.schedule || []).map((row) => {
|
|
const key = sourceKey(row);
|
|
return { ...row, weight: key ? Math.max(0, Number(rows.get(key).TC) || 0) : row.weight };
|
|
});
|
|
}
|
|
function refreshProgress(project) {
|
|
const round = (project.progress || []).findLast((item) => item.locked && item.result);
|
|
if (!round) throw Error("확정된 기성 회차가 없습니다.");
|
|
const { sourceKey } = estimateRows(project),
|
|
saved = new Map();
|
|
for (const row of round.result.rows) {
|
|
if (
|
|
Number(row.Level) > 0 ||
|
|
Number(row.CalcIndex) >= 2 ||
|
|
[54, 55, 58, 59].includes(Number(row.LinkIndex))
|
|
)
|
|
continue;
|
|
const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : "";
|
|
if (!key) continue;
|
|
if (saved.has(key)) throw Error(`기성 내역 식별자 중복: ${key}`);
|
|
saved.set(key, row);
|
|
}
|
|
return (project.schedule || []).map((entry) => {
|
|
const row = saved.get(sourceKey(entry));
|
|
if (!row || !Number(row.GN)) return { ...entry };
|
|
const progress = (Number(row.TotalGN) / Number(row.GN)) * 100;
|
|
if (!Number.isFinite(progress) || progress < 0 || progress > 100)
|
|
throw Error(`${entry.Name || entry.Code}: 기성 누계 진도가 0~100% 범위를 벗어났습니다.`);
|
|
return { ...entry, progress };
|
|
});
|
|
}
|
|
function moveRow(project, index, direction) {
|
|
if (!project.schedule || !Array.isArray(project.schedule)) return;
|
|
const target = index + direction;
|
|
if (
|
|
index < 0 ||
|
|
index >= project.schedule.length ||
|
|
target < 0 ||
|
|
target >= project.schedule.length
|
|
)
|
|
return;
|
|
const temp = project.schedule[index];
|
|
project.schedule[index] = project.schedule[target];
|
|
project.schedule[target] = temp;
|
|
}
|
|
function clearSchedule(project) {
|
|
project.schedule = [];
|
|
}
|
|
function render() {
|
|
const { esc, number, state } = EstApp,
|
|
output = calculate(state.project);
|
|
const rowsCount = output.rows.length;
|
|
EstApp.byId("work-content").innerHTML =
|
|
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
|
|
}
|
|
function install() {
|
|
document.addEventListener("click", (event) => {
|
|
const node = event.target.closest("[data-action]");
|
|
if (!node) return;
|
|
const p = EstApp.state.project;
|
|
if (
|
|
["schedule-import", "schedule-weights", "schedule-progress"].includes(node.dataset.action)
|
|
) {
|
|
try {
|
|
if (node.dataset.action === "schedule-import") {
|
|
const added = fromEstimate(p, EstApp.uid);
|
|
EstApp.change(() => (p.schedule ||= []).push(...added), false);
|
|
} else {
|
|
const rows =
|
|
node.dataset.action === "schedule-progress" ? refreshProgress(p) : refreshWeights(p);
|
|
EstApp.change(() => (p.schedule = rows), false);
|
|
}
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
}
|
|
return;
|
|
}
|
|
if (node.dataset.action === "schedule-add") {
|
|
try {
|
|
const row = fromEstimate(p, EstApp.uid)[0] || {
|
|
id: EstApp.uid(),
|
|
Code: "",
|
|
Name: "새 공정",
|
|
start: "",
|
|
end: "",
|
|
weight: 0,
|
|
progress: 0,
|
|
};
|
|
EstApp.change(() => (p.schedule ||= []).push(row), false);
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
}
|
|
}
|
|
if (node.dataset.action === "schedule-move") {
|
|
const index = Number(node.dataset.index),
|
|
direction = Number(node.dataset.direction);
|
|
EstApp.change(() => moveRow(p, index, direction), false);
|
|
}
|
|
if (node.dataset.action === "schedule-clear") {
|
|
if (confirm("예정공정표의 모든 공정 항목을 비우시겠습니까?"))
|
|
EstApp.change(() => clearSchedule(p), false);
|
|
}
|
|
if (node.dataset.action === "schedule-delete")
|
|
EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false);
|
|
});
|
|
document.addEventListener("change", (event) => {
|
|
const node = event.target;
|
|
if (!node.dataset.schedule) return;
|
|
const p = structuredClone(EstApp.state.project),
|
|
row = p.schedule[Number(node.dataset.index)],
|
|
key = node.dataset.schedule;
|
|
try {
|
|
if (key === "Code" && row.Code !== node.value) delete row.SourceKey;
|
|
row[key] = ["weight", "progress"].includes(key) ? Number(node.value) : node.value;
|
|
validate(p);
|
|
EstApp.change(() => (EstApp.state.project = p), false);
|
|
} catch (error) {
|
|
EstApp.state.notice = error.message;
|
|
EstApp.render();
|
|
}
|
|
});
|
|
}
|
|
return {
|
|
validate,
|
|
calculate,
|
|
fromEstimate,
|
|
refreshWeights,
|
|
refreshProgress,
|
|
moveRow,
|
|
clearSchedule,
|
|
render,
|
|
install,
|
|
};
|
|
})();
|