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); 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 render() { const { esc, number, state } = EstApp, output = calculate(state.project); EstApp.byId("work-content").innerHTML = `
시작 ${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}
`; } function install() { document.addEventListener("click", (event) => { const node = event.target.closest("[data-action]"); if (!node) return; const p = EstApp.state.project; if (node.dataset.action === "schedule-add") EstApp.change(() => { p.schedule ||= []; const row = p.tables.EstMainDat.find( (r) => r.Code && !Number(r.Level) && !p.schedule.some((s) => s.Code === r.Code), ); p.schedule.push({ id: crypto.randomUUID(), Code: row?.Code || "", Name: row?.Name || "새 공정", start: "", end: "", weight: Math.max(0, Number(row?.TC) || 0), progress: 0, }); }, 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 { 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, render, install }; })();