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 }; } // only = 고른 내역 행(예정공정표에 표시 mnuShowSchedule) — 없으면 모든 상세 행 function fromEstimate(project, makeId, only) { const { rows, sourceKey } = estimateRows(project), existing = new Set((project.schedule || []).map(sourceKey).filter(Boolean)); return [...rows.entries()] .filter(([key, row]) => !existing.has(key) && (!only || only.has(row))) .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 = []; } // 원본 예정공정표 눈금(mnu1Day·mnu5Day·mnu10Day·mnu15Day·mnu20Day·mnu1Week·mnu1Month). const scales = [ ["1d", "1일", 1], ["5d", "5일", 5], ["10d", "10일", 10], ["15d", "15일", 15], ["20d", "20일", 20], ["1w", "1주", 7], ["1m", "1개월", 0], ]; function ticks(start, end, scale, limit = 60) { if (!start || !end) return { ticks: [], cut: false }; const first = day(start), span = day(end) - first + 1, step = scales.find(([key]) => key === scale)?.[2]; if (step == null) throw Error("눈금 단위를 고르세요."); const out = []; if (step) for (let d = first; d < first + span; d += step) out.push(d); else { const date = new Date(first * 86400000); for (let y = date.getUTCFullYear(), m = date.getUTCMonth(); ; m++) { const t = Date.UTC(y, m, 1) / 86400000; if (t >= first + span) break; out.push(Math.max(t, first)); } } const cut = out.length > limit; return { cut, ticks: out.slice(0, limit).map((d) => ({ left: ((d - first) / span) * 100, label: new Date(d * 86400000).toISOString().slice(step ? 5 : 0, step ? 10 : 7), })), }; } function render() { const { esc, number, state } = EstApp, output = calculate(state.project); state.scheduleScale ||= "1m"; const grid = ticks(output.start, output.end, state.scheduleScale), lines = grid.ticks .map((t) => `linear-gradient(#999,#999) ${t.left}% 0/1px 100% no-repeat`) .join(","), width = Math.max(180, grid.ticks.length * 44), header = `
${grid.ticks.map((t) => `${esc(t.label)}`).join("")}
`, scaleSelect = `${grid.cut ? "눈금이 많아 앞 60개만 표시" : ""}`; const rowsCount = output.rows.length; EstApp.byId("work-content").innerHTML = `
${scaleSelect}시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h === "기간" ? `기간${header}` : h}
`; } 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", "schedule-mark"].includes( node.dataset.action, ) ) { try { if (node.dataset.action === "schedule-mark") { const state = EstApp.state, picked = new Set( [...state.selection].map((i) => p.tables.EstMainDat[i]).filter(Boolean), ); if (state.view !== "EstMainDat" || !picked.size) throw Error("내역서에서 공정표에 표시할 행을 고르세요."); const added = fromEstimate(p, EstApp.uid, picked); if (added.length) { EstApp.change(() => (p.schedule ||= []).push(...added), false); state.notice = `${added.length}행을 예정공정표에 넣었습니다.`; } else { // 고른 행이 모두 이미 있으면 해제(popMainShowChart 표시/해제) const { sourceKey, rows } = estimateRows(p), keys = new Set([...rows].filter(([, r]) => picked.has(r)).map(([k]) => k)); EstApp.change( () => (p.schedule = (p.schedule || []).filter((r) => !keys.has(sourceKey(r)))), false, ); state.notice = `${keys.size}행을 예정공정표에서 뺐습니다.`; } EstApp.render(); } else 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.scheduleScale !== undefined) { EstApp.state.scheduleScale = node.value; EstApp.render(); return; } 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, ticks, fromEstimate, refreshWeights, refreshProgress, moveRow, clearSchedule, render, install, }; })();