Files
Aislo/tools/est-test/native-progress-view.js
T
eomsangdonandClaude Opus 5.5 4706229100 feat(est-test): 기성 회차 도급내역 선택(당초/변경)
- 설계변경 내역서에서 회차 추가 때 당초/변경 고르기
- 당초면 행마다 당초 칸으로 Index·품명·규격·단위·수량·단가·금액(GC 포함)
- 회차 추가 처리를 native-progress-view.js 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:06:09 +09:00

334 lines
16 KiB
JavaScript

// 기성고 화면(native-extra.js 에서 옮김)과 원본 기성 「환경설정」(TfrmAccomOption — spec_pb1 §4: 주 메뉴 환경 설정 0x10ccddc 가
// 기성 문서일 때 · 기성 원가계산서 팝업 popupOptision 0xd479f4 Ctrl+U). 칸을 누르면 바로 옵션에 쓰고 「취소」면 연 때 값으로 되돌림(d45e1c).
// 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·품명 두줄·Enter 이동·바탕색)은 다른 표와 같은 계산 환경 Display,
// 기성고(레벨·비고란 발췌근거·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 원본도 처리기가 없는
// 자동저장 분·가로세로 계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만 담는다(화면·계산 불변).
const EstProgressView = (() => {
const num = (v) => Number(v) || 0;
const COMMON = {
code: true,
gnZero: true,
costZero: true,
order: 0,
align: 0,
twoLine: false,
enter: 0,
color: "",
autoSave: false,
saveTime: 10,
};
const ACCOM = {
level: false,
gungu: false,
sumBold: false,
calcCheck: false,
horCalc: 0,
sajung: true,
gubun: false,
siljuk: false,
gcTo: 0,
eyunRound: false,
};
const get = (options) => ({
...COMMON,
...(options?.Display || {}),
...ACCOM,
...(options?.AccomOption || {}),
});
const ORDERS = ["TNGJ", "TGNJ", "NGJT", "GNJT"];
const PART = { T: "합계", N: "노무", G: "재료", J: "경비", S: "실적" };
// 비목 금액 열(금회): 비목순서대로, 실적은 실적 비목 표시 때만 경비 뒤에
function parts(o) {
const seq = [...(ORDERS[o.order] || ORDERS[0])];
if (o.siljuk) seq.splice(seq.indexOf("J") + 1, 0, "S");
return seq;
}
function view(state, project, { box, button, table }) {
const { esc } = EstApp,
o = get(project.options);
const shown = (v, zero) => (!zero && !num(v) ? "" : EstApp.number(v));
const qty = (v) => shown(v, o.gnZero),
cost = (v) => shown(v, o.costZero);
state.roundId ||= project.progress[0]?.id;
const round = project.progress.find((r) => r.id === state.roundId);
let body = '<p class="empty">회차를 추가하세요.</p>',
tools =
button("add-round", "기성 회차 추가") +
'<label>원본 기성 표 확인 <input type="file" accept=".estx" data-accom-inspect></label>' +
(project.progress.length ? button("clear-progress", "기성 전체 초기화") : "") +
button("accom-option", "환경 설정");
if (round) {
tools +=
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
button(
round.locked ? "unlock-round" : "lock-round",
round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장",
) +
button("fill-progress", "전량·비율 일괄 입력") +
button("rename-round", "회차 이름") +
button("delete-round", "회차 삭제") +
button("reset-round-inputs", "회차 입력 초기화") +
`<label><input id="progress-components" type="checkbox" ${round.separateComponents !== false ? "checked" : ""} ${round.locked ? "disabled" : ""}> 비목별 계산</label><label>금액 소수자리 <input id="progress-digits" type="number" min="0" max="4" value="${round.amountDigits ?? 0}" ${round.locked ? "disabled" : ""}></label>` +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
let output;
try {
output =
round.locked && round.result ? round.result : EstBusiness.progress(project, round.id);
} catch (e) {
output = { rows: [], errors: [e.message] };
}
state.business = output;
const page = EstBlockActions.progressPage(state, output.rows);
tools +=
button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") +
`<span>${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행</span>` +
button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : "");
const seq = o.gubun ? parts(o) : ["T"];
const nameStyle = [
["", "text-align:right", "text-align:center"][o.align] || "",
o.twoLine ? "white-space:normal;max-width:16em" : "white-space:nowrap",
]
.filter(Boolean)
.join(";");
const headers = [
...(o.code ? ["코드"] : []),
...(o.level ? ["레벨"] : []),
"공종",
"도급수량",
"전회수량",
"금회 입력",
"입력방법",
...(o.sajung ? ["사정 입력", "입력방법"] : []),
...seq.map((p) => (p === "T" ? "금회금액" : `금회 ${PART[p]}`)),
...(o.sajung ? ["사정금액"] : []),
"누계수량",
"누계금액",
...(o.gungu ? ["비고"] : []),
];
body =
table(
headers,
page.rows.map(({ row, index }) => {
const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex));
const key = EstBusiness.rowKey(row, index),
input = round.inputs?.[key] || {};
const edit = (prefix) =>
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? row[prefix + "GN"] ?? 0}" ${round.locked || !editable || input[prefix + "Mode"] === "components" ? "disabled" : ""}>` +
(input[prefix + "Mode"] === "components"
? EstMath.components
.map(
(field, i) =>
`<label>${["노무", "재료", "경비", "실적"][i]}<input data-progress="${prefix + field}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix + field] ?? 0}" ${round.locked ? "disabled" : ""}></label>`,
)
.join("")
: "");
const method = (prefix) =>
!editable
? "자동계산"
: `<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
["components", "비목 금액"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
const bold = o.sumBold && num(row.Level) > 0 ? ";font-weight:bold" : "";
return [
...(o.code ? [esc(row.Code)] : []),
...(o.level ? [num(row.Level) || ""] : []),
`<span style="display:block;${nameStyle}${bold}">${esc(row.Name)}</span>`,
qty(row.GN),
qty(row.JunGN),
edit("Gum"),
method("Gum"),
...(o.sajung ? [edit("Sa"), method("Sa")] : []),
...seq.map((p) => cost(row[`Gum${p}C`])),
...(o.sajung ? [cost(row.SaTC)] : []),
qty(row.TotalGN),
cost(row.TotalTC),
...(o.gungu ? [esc(row.Bigo ?? "")] : []),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${EstApp.number(output.total)} · 기성비율 ${EstApp.number(output.ratio)}%</p>`;
body += EstProgressWonga.render(state, round, output);
}
const html = box(
"기성고",
tools,
body,
"전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
);
return o.color ? `<div style="background:${esc(o.color)}">${html}</div>` : html;
}
// 환경설정 창: 칸을 바꾸면 바로 옵션에 쓰고(화면에 즉시), 「취소」 는 연 때 값으로 되돌림
let opened = null;
function dialog(state) {
const { esc } = EstApp,
o = get(state.project.options);
opened = {
Display: structuredClone(state.project.options.Display),
AccomOption: structuredClone(state.project.options.AccomOption),
};
const check = (name, label, extra = "") =>
`<label><input type="checkbox" data-ao="${name}" ${o[name] ? "checked" : ""} ${extra}> ${esc(label)}</label>`;
const pick = (name, label, choices, extra = "") =>
`<label>${esc(label)} <select data-ao="${name}" ${extra}>${choices.map((c, i) => `<option value="${i}" ${num(o[name]) === i ? "selected" : ""}>${esc(c)}</option>`).join("")}</select></label>`;
EstApp.showDialog(
"환경설정",
`<h4>공통적용</h4><div class="form-fields">${check("code", "코드표시")}${check("gnZero", '수량 "0" 표시')}${check("costZero", '금액 "0" 표시')}${check("autoSave", "데이타 자동저장")}<label>데이타 입력이 없으면 <input type="number" min="5" max="30" step="5" data-ao="saveTime" value="${num(o.saveTime)}" ${o.autoSave ? "" : "disabled"}> 분후 자동저장</label>${pick("order", "비목순서", ["합/노/재/경", "합/재/노/경", "노/재/경/합", "재/노/경/합"], o.gubun ? "" : "disabled")}${pick("align", "품명정렬", ["좌정렬", "우정렬", "중앙정렬"])}${pick("twoLine", "품명표기", ["한줄로 표시", "두줄로 표시"])}${pick("enter", "Enter 뒤 셀 이동", ["이동안함", "좌", "우", "위", "아래"])}<label>바탕색 <input type="color" data-ao="color" value="${esc(o.color || "#ffffff")}"></label></div>` +
`<h4>기성고</h4><div class="form-fields">${check("level", "레벨 표시")}${check("gungu", "비고란에 발췌근거 표시")}${check("sumBold", "집계공종 진하게 표시")}${check("calcCheck", "가로,세로 계산 확인")}${pick("horCalc", "데이타(가로) 계산방법", ["절하", "절상"])}${check("sajung", "사정금액 표시")}${check("gubun", "노,재,경 구분 표시")}${check("siljuk", "실적 비목 표시", o.gubun ? "" : "disabled")}</div>` +
`<h4>원가계산</h4><div class="form-fields">${pick("gcTo", "순직접공사비의 재료비 처리", ["간접재료비", "직접재료비"])}${check("eyunRound", "이윤 비율 사사오입")}</div>` +
`<button data-action="accom-option-ok">적 용</button> <button data-action="accom-option-cancel">취 소</button>`,
);
}
function set(name, value) {
const options = EstApp.state.project.options,
bucket = name in COMMON ? "Display" : "AccomOption";
const flag = typeof COMMON[name] === "boolean" || typeof ACCOM[name] === "boolean";
const typed = flag
? typeof value === "string"
? value === "1"
: Boolean(value)
: name === "color"
? value
: num(value);
options[bucket] = { ...(options[bucket] || {}), [name]: typed };
const box = document.getElementById("dialog-content");
if (name === "autoSave") box.querySelector('[data-ao="saveTime"]').disabled = !typed;
if (name === "gubun") {
box.querySelector('[data-ao="order"]').disabled = !typed;
box.querySelector('[data-ao="siljuk"]').disabled = !typed;
}
EstApp.render();
}
function close(apply) {
const options = EstApp.state.project.options;
if (opened) {
const now = { Display: options.Display, AccomOption: options.AccomOption };
for (const k of ["Display", "AccomOption"])
if (opened[k] === undefined) delete options[k];
else options[k] = opened[k];
// 적용: 연 때 값으로 돌린 뒤 바뀐 값을 한 번에 기록(되돌리기 한 단계)
if (apply) EstApp.change((project) => Object.assign(project.options, now), false);
}
opened = null;
EstApp.byId("dialog").close();
EstApp.render();
}
// Enter 뒤 셀 이동(ComboBoxEnter 62c+0x60): 표 안 입력칸에서 Enter 면 좌·우·위·아래 칸으로
function move(input, direction) {
const cell = input.closest("td"),
tr = cell?.parentElement;
if (!tr) return null;
const col = [...tr.children].indexOf(cell);
let target = null;
if (direction === 1 || direction === 2) {
const cells = [...tr.children];
for (
let c = col + (direction === 1 ? -1 : 1);
c >= 0 && c < cells.length;
c += direction === 1 ? -1 : 1
)
if ((target = cells[c].querySelector("input:not([disabled]),select:not([disabled])")))
break;
} else if (direction === 3 || direction === 4) {
for (
let r = direction === 3 ? tr.previousElementSibling : tr.nextElementSibling;
r;
r = direction === 3 ? r.previousElementSibling : r.nextElementSibling
)
if (
(target = r.children[col]?.querySelector("input:not([disabled]),select:not([disabled])"))
)
break;
}
return target;
}
function install() {
document.addEventListener("change", (event) => {
const t = event.target;
if (t.dataset?.ao === undefined) return;
set(t.dataset.ao, t.type === "checkbox" ? t.checked : t.value);
});
document.addEventListener("keydown", (event) => {
const state = EstApp.state;
if (state?.view !== "progress") return;
if (event.ctrlKey && event.key.toLowerCase() === "u") {
event.preventDefault();
dialog(state);
return;
}
const t = event.target;
if (event.key !== "Enter" || !t.matches?.("table.sheet input, table.sheet select")) return;
const direction = num(get(state.project.options).enter);
if (!direction) return;
const target = move(t, direction);
if (target) {
event.preventDefault();
t.dispatchEvent(new Event("change", { bubbles: true }));
setTimeout(() =>
(
document.querySelector(
`[data-key="${CSS.escape(target.dataset.key || "")}"][data-progress="${target.dataset.progress}"]`,
) || target
).focus(),
);
}
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
// 기성 회차 추가: 설계변경 내역서면 「도급내역을 선택하세요.」(당초/변경 — 닫기 없음, 고른 값으로 회차를 만듦)
function addRound(state) {
const project = state.project,
push = (loadMethod) => {
const next = {
id: EstApp.uid(),
name: EstBusiness.newProgressName(project.progress),
inputs: {},
approved: false,
locked: false,
...(loadMethod && { loadMethod }),
};
EstApp.change(() => {
project.progress.push(next);
state.roundId = next.id;
}, false);
};
if (project.options.Mode !== "설계변경") return push();
EstActions.form(
"도급내역을 선택하세요.",
[
{
name: "method",
label: "설계변경한 내역서입니다. 도급내역으로 불러올 데이타를 선택하세요.",
value: "origin",
choices: [
["origin", "당초 내역서"],
["change", "변경 내역서"],
],
},
],
(values) => push(values.method),
);
}
function handle(name, state) {
if (name === "accom-option") dialog(state);
else if (name === "accom-option-ok") close(true);
else if (name === "accom-option-cancel") close(false);
else return false;
return true;
}
return { view, get, parts, move, handle, dialog, addRound };
})();