Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""EST 테스트 앱 단가산출 전역변수(원본 \x05 표지 — 호표를 넘어 값 유지, 지정·해제, 목록)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_global_variable_crosses_groups_and_toggle():
|
||||
code = r"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({ structuredClone });
|
||||
for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-global-vars.js'])
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f });
|
||||
const T = vm.runInContext('EstText', ctx), G = vm.runInContext('EstGlobalVars', ctx);
|
||||
const rows = [
|
||||
{ Index: 1, Code: 'D1', LineData: '호표1' },
|
||||
{ Index: 0, SCheck: 0, LineData: '\x05Q\x05 = 2 × 3 = \x126\x12' },
|
||||
{ Index: 1, Code: 'D2', LineData: '호표2' },
|
||||
{ Index: 0, SCheck: -1, LineData: '노무 = Q × 10 = \x120\x12' },
|
||||
{ Index: 1, Code: 'D3', LineData: '호표3' },
|
||||
{ Index: 0, SCheck: 0, LineData: 'R = 1 = \x121\x12' },
|
||||
{ Index: 1, Code: 'D4', LineData: '호표4' },
|
||||
{ Index: 0, SCheck: -1, LineData: '노무 = R × 10 = \x120\x12' },
|
||||
];
|
||||
const r = T.calculate(rows, {});
|
||||
let bad = ''; try { G.toggle('가 = 3', 0); } catch (e) { bad = e.message.slice(0, 12); }
|
||||
process.stdout.write(JSON.stringify({
|
||||
used: r.rows[3].LineData.match(/\x12([^\x12]+)\x12/)[1], kept: r.rows[1].LineData.startsWith('\x05Q\x05'),
|
||||
local: r.errors.length, globals: r.globals,
|
||||
on: G.toggle('Q = 2', 0), off: G.toggle('⟦전역⟧Q⟦전역⟧ = 2', 5), bad,
|
||||
clean: T.clean('\x05Q\x05 = 1'), shown: T.display('\x05V1\x05'), back: T.encode('⟦전역⟧V1⟦전역⟧') === '\x05V1\x05',
|
||||
}));
|
||||
"""
|
||||
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
|
||||
assert out["used"] == "60"
|
||||
assert out["kept"] is True
|
||||
assert out["local"] == 1 # R 은 전역이 아니라 D4 에서 못 찾음
|
||||
assert out["globals"] == [{"name": "Q", "value": 6, "index": 1}]
|
||||
assert out["on"] == "⟦전역⟧Q⟦전역⟧ = 2"
|
||||
assert out["off"] == "Q = 2"
|
||||
assert out["bad"] == "선택한 변수는 전역변수"
|
||||
assert out["clean"] == "Q = 1"
|
||||
assert out["shown"] == "⟦전역⟧V1⟦전역⟧" and out["back"] is True
|
||||
@@ -0,0 +1,32 @@
|
||||
"""EST 테스트 앱 JDLExcel과 단가비교(원본 ActionJDLCompare — Excel 코드 칸이 코드를 품으면 같은 행 합계와 TC 비교)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_compare_contains_code_limit_and_xls_tables():
|
||||
code = r"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/native-jdl-compare.js', 'utf8'), ctx);
|
||||
const J = vm.runInContext('EstJdlCompare', ctx);
|
||||
const rows = [{ Code: 'U001', Name: '가', TC: 1000 }, { Code: 'U002', Name: '나', TC: 500 },
|
||||
{ Code: 'U003', Name: '다', TC: 7 }, { Code: '', Name: '머리' }];
|
||||
const table = [['코드', '', '합계'], ['[U001] 가', '', '1,000'], ['U002', '', 499]];
|
||||
const out = J.compare(rows, table, 0, 2).map((r) => [r.Code, r.OTC, r.GN, r.Checked]);
|
||||
const far = Array.from({ length: 60 }, () => ['x']); far.push(['U009', '', 5]);
|
||||
const limit = J.compare([{ Code: 'U009', TC: 5 }], far, 0, 2)[0].OTC;
|
||||
const xls = J.xlsTables([{ name: 'S1', cells: { '0,0': { value: 'U001' }, '0,7': { value: 1000 }, '2,1': { value: 'b' } } }]);
|
||||
process.stdout.write(JSON.stringify({ out, limit, xls: xls.S1 }));
|
||||
"""
|
||||
out = json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
|
||||
assert out["out"] == [
|
||||
["U001", 1000, 0, False],
|
||||
["U002", 499, 1, True],
|
||||
["U003", None, None, True],
|
||||
]
|
||||
assert out["limit"] is None
|
||||
assert out["xls"][0][0] == "U001" and out["xls"][0][7] == 1000 and out["xls"][2][1] == "b"
|
||||
@@ -34,3 +34,22 @@ def test_search_finds_words_in_selected_kinds(tmp_path):
|
||||
assert result["none"]["total"] == 0
|
||||
assert result["excluded"]["total"] == 0
|
||||
assert result["err"] == ["검색어를", "검색할 "]
|
||||
|
||||
|
||||
def test_search_price_range_and_main_data_only(tmp_path):
|
||||
sample = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8"))
|
||||
(tmp_path / ("a" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
|
||||
(tmp_path / ("c" * 24 + ".json")).write_text(json.dumps(sample), encoding="utf-8")
|
||||
script = """
|
||||
const s = require('./tools/est-test/search-store.cjs'), dir = process.argv[1];
|
||||
const range = s.search(dir, '1~999999999', ['5']);
|
||||
const exact = s.search(dir, String(range.hits[0].price), ['5']);
|
||||
const only = s.search(dir, '1~999999999', ['5'], '', ['c'.repeat(24)]);
|
||||
process.stdout.write(JSON.stringify({ range: range.hits.map((h) => h.price), exact: exact.total, only: only.hits.map((h) => h.id), total: range.total }));
|
||||
"""
|
||||
out = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
|
||||
)
|
||||
assert out["range"] and all(1 <= p <= 999999999 for p in out["range"])
|
||||
assert out["exact"] >= 2
|
||||
assert set(out["only"]) == {"c" * 24} and len(out["only"]) == out["total"] // 2
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""EST 테스트 앱 줄 연결 도구(원본 비목별 단가 불러오기 · SUM 색인지정 · 하위데이타 연결/끊기)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def run_js(code: str):
|
||||
runner = f"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({{ structuredClone }});
|
||||
for (const f of ['native-autolink.js', 'native-row-links.js'])
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
|
||||
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
|
||||
"""
|
||||
return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
|
||||
|
||||
|
||||
def test_cost_part_sum_index_and_toggle():
|
||||
out = run_js(
|
||||
r"""(() => {
|
||||
const L = EstRowLinks, p = { tables: {
|
||||
CostTitleDat: [{ Code: 'D1', Name: '거푸집', Size: '합판', Unite: 'm2', TC: 60, NC: 40, GC: 15, JC: 5, SC: 0 }],
|
||||
StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40kg', Unite: '포', TC: 5000, GC: 5000 }],
|
||||
LaborcDat: [], HeadTitleDat: [], MachTitleDat: [], SilJukDat: [], EstiMateDat: [] } };
|
||||
const r = { LinkIndex: 104, Code: 'D1', NU: 1, GU: 1, JU: 1, SU: 1, TU: 9 };
|
||||
L.costPart(p, r, 3);
|
||||
const jc = [r.NU, r.GU, r.JU, r.SU, r.OCheck, r.TU];
|
||||
L.costPart(p, r, 0);
|
||||
const all = [r.NU, r.GU, r.JU, r.OCheck];
|
||||
let refused = ''; try { L.costPart(p, { LinkIndex: 5 }, 0); } catch (e) { refused = e.message; }
|
||||
const est = { Code: '', Name: '시멘트', Size: '40kg', Unite: '포', LinkIndex: 0 };
|
||||
const linked = [L.toggle(p, 'EstMainDat', est), est.Code, est.LinkIndex, est.GU, est.TU];
|
||||
const unlinked = [L.toggle(p, 'EstMainDat', est), est.LinkIndex, est.Code];
|
||||
let none = ''; try { L.toggle(p, 'EstMainDat', { Name: '없음', LinkIndex: 0 }); } catch (e) { none = e.message; }
|
||||
const skip = L.toggle(p, 'EstMainDat', { LinkIndex: 54 });
|
||||
const s = { LinkIndex: 0, Index: 0 }; L.sumIndex('EstMainDat', s, 3);
|
||||
const bad = []; for (const row of [{ Level: 1, GN: 0 }, { LinkIndex: 53 }, { Index: 1 }])
|
||||
try { L.sumIndex('EstMainDat', row, 1); } catch (e) { bad.push(e.message.slice(0, 6)); }
|
||||
return { jc, all, refused, linked, unlinked, none, skip, skind: s.SKind, bad };
|
||||
})()"""
|
||||
)
|
||||
assert out["jc"] == [0, 0, 5, 0, 3, 9]
|
||||
assert out["all"] == [40, 15, 5, 0]
|
||||
assert out["refused"] == "노임,자재,표준시장단가는 적용할수 없습니다."
|
||||
assert out["linked"] == ["link", "M1", 5, 5000, 5000]
|
||||
assert out["unlinked"] == ["unlink", 0, "M1"]
|
||||
assert out["none"] == "해당 하위data를 찾지못해 연결시키지 못했습니다."
|
||||
assert out["skip"] == "skip"
|
||||
assert out["skind"] == 3
|
||||
assert out["bad"] == ["레벨지정한 ", "현재라인의 ", "현재라인의 "]
|
||||
@@ -489,6 +489,23 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
|
||||
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
|
||||
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
|
||||
- [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움.
|
||||
- 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구.
|
||||
- [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음.
|
||||
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 GN_ 5·신규 표시·당초 사본 둔 샘플 → 새 작업에는 GN_ 없음·신규 끔·당초 사본 없음, 현재 화면은 GN_ 5 그대로, 시험 작업 지우고 샘플 복구.
|
||||
- [x] 단가산출 전역변수(원본 mnuCostSetGlobalVar 0xf053cc · mnuCostGlobalvarList · 전역표 0x93c22c — spec_cost_tools §2) — LineData 의 이름(화면 ⟦전역⟧) 로 대입한 변수는 호표가 바뀌어도 남아 다른 호표에서 표지 없이 써도 찾음(그 호표 변수 → 전역표 차례). 「전역변수 지정,해제」: 마지막 산출근거 칸 커서 아래 낱말(영문자 포함 [0-9A-Za-z_])을 감싸거나 풂(원본 거부 문구). 「전역변수 목록」: 처음부터 계산한 대입마다 변수명·값·이동. 원본처럼 전역표는 저장하지 않고 계산 때마다 다시 만듦.
|
||||
- 검증완료: `test_est_global_vars.py`(다른 호표 Q×10 = 60, 표지 유지, 비전역 R 은 못 찾음, 지정·해제·거부, 표시·되돌림) 및 REA 대조 39개 통과. ORCA 브라우저에서 샘플 단가산출 q 줄 지정 → 목록 「q 0.7」·이동으로 그 줄 선택, 도급액 불변, 샘플 복구.
|
||||
- [x] JDLExcel과 단가비교(원본 ActionJDLCompare, 숨은 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 탭, 시트번호·코드칼럼(A)·합계셀칼럼(H), Excel(.xlsx·.xls — `/api/read-xls`) 1행~(호표 수+50)행에서 코드를 품은 칸의 같은 행 합계와 TC 비교, 차이·없음 표시. 보기 전용. 웹은 메뉴 단추로 엶.
|
||||
- 검증완료: `test_est_jdl_compare.py`(같음·차이 1·없음·+50행 한계·xls 셀 변환) 통과. ORCA 브라우저에서 일위대가 8개 중 같음 1·차이 10·없음 6 표시.
|
||||
- 제외: TfrmMultiEditor(인쇄 부품의 설계용 편집기 — 실행 파일에 클래스 없음), Est Plus(구버전 .est) 단가비교·내보내기(숨은 단축키, 394바이트 레코드 형식 — 구버전 불러오기와 함께 남음).
|
||||
- [x] 검색 결과 근거자료보기(원본 TfrmFoundDetail btnClose 0xefa7e8) — 통합 검색 결과의 일위·단산·기계 줄 「근거」: 그 작업의 호표 세부 줄(단산은 산출근거 글)과 총액·노무·재료·경비, 「적용」은 그 결과를 고르고 검색 창으로(가져오기는 검색 창에서). 고른 결과는 창을 오가도 유지.
|
||||
- 검증완료: 바인딩·통합 검색 시험 통과. ORCA 브라우저에서 「거푸집」 일위 → 「[합판거푸집]의 근거자료보기」 총액 53,029·노무 38,289·재료 14,740·세부 6줄, 적용 → 그 결과 체크.
|
||||
- [x] ES 정보(원본 mnuESInfo → TfrmESInfo 0x10b838c) — 기준시점·비교시점·최종기성일·선급금지급일(기본 오늘)·선급금지급액을 `project.esInfo` 에 기록(원본 [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost). 원본도 ES 제출 파일 작성에만 씀 — 웹 물가변동 계산과 잇지 않음, ESTX 에는 남지 않음.
|
||||
- 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 기본 오늘 · 기준시점 2025-01-15·선급금 1,000,000 저장 → 다시 열면 그대로, 지워 복구.
|
||||
- [x] 설계참고철(Main Data)과 단가 범위 검색(원본 TfrmMainTableList 「MAIN DATA(설계참고철) 추가 및 삭제」 · TfrmFolderSerch 「Main Data에서 검색」·범위 검색 0xefefb0) — 통합 검색 「설계참고철 관리」에서 저장된 작업 중 최대 10개 지정(양식 보관함 종류 options), 검색위치 「설계참고철」이면 그 작업만, 검색어 「a~b」·숫자 하나는 단가 범위·같음. 원본의 검색중지는 서버 검색이 바로 끝나 두지 않음.
|
||||
- 검증완료: `test_est_multi_search.py` 2개(범위·같음·지정 작업만) 통과. ORCA 브라우저에서 2개 지정 → 「1000~2000」 24건 모두 지정 작업·범위 안, 목록 지워 복구.
|
||||
- [x] 줄 연결 도구(원본 TfrmHead popupCostNC·GC·JC·All 0xf1b54c · popMainSumIndex · mnuDataLink 0xf00e6c — 디스어셈블 근거) — 비목별 단가 불러오기(일위대가 줄: 하위 견적·일위·단산·기계에서 고른 비목 단가만 남기고 나머지 0, 기준비목 지정, 노임·자재·표준 거부), SUM 색인지정(해제·A~Z → 구간 SKind, 레벨·소계·도급액·조달수수료·호표머리 줄 거부), 하위데이타 연결/끊기(연결 줄은 끊고 값 유지, 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임 차례로 코드 또는 품명·규격·단위 맞춤으로 연결, 자재는 재료단가·노임은 노무단가 = 원본 TC). 원본은 초점 한 줄, 웹은 고른 줄 모두.
|
||||
- 검증완료: `test_est_row_links.py`(경비만 5·전체 40/15/5, 거부, 자재 연결 M1·5000·끊기, 못 찾음 문구, 색인 3·거부 3종) 통과. ORCA 브라우저에서 일위대가 합판거푸집 노무비만 → 0, 재료비만 → 재료단가 34,666·금액 13,173, 내역 일위 연결 줄 끊기 → 다시 연결 103·도급액 불변, SUM 색인 B, 샘플 복구.
|
||||
- [x] 묶음 인쇄 간지 설정값 일괄적용·기본양식에저장·전체선택/선택해제(원본 TFRMESTPRINTPRIVATE btnGangiAllSet·btnSaveToMain, TFRMESTPRINT2 btnNoselect) — 간지 인쇄·간지 겹테두리를 모든 서식에 같게, 고른 서식·간지 설정을 「묶음기본양식」(양식 보관함 종류 print)으로 저장해 묶음 설정 없는 공사에서 열 때 씀.
|
||||
- 검증완료: 묶음 인쇄·바인딩 시험 통과. ORCA 브라우저에서 선택해제 0 · 전체선택 40, 겹테두리 끔 → 미리보기 간지 테두리 없음, 기본양식 저장 뒤 설정 지우고 다시 열면 1개·겹테두리 끔 복원, 양식 지우고 샘플 복구.
|
||||
- [x] 갑지 공사금액 자동변경(원본 TFRMGABGI popupCostChange) — 「공사금액 자동변경」 체크 시 고른 원가 비목·방식을 기억해 인쇄 때마다 현재 원가 금액으로 공사금액 글을 다시 만듦(다시 열면 고른 비목·방식 그대로).
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
<script defer src="native-doc-forms.js"></script>
|
||||
<script defer src="native-block-file.js"></script>
|
||||
<script defer src="native-cell-clip.js"></script>
|
||||
<script defer src="native-row-links.js"></script>
|
||||
<script defer src="native-es-info.js"></script>
|
||||
<script defer src="native-jdl-compare.js"></script>
|
||||
<script defer src="native-global-vars.js"></script>
|
||||
<script defer src="native-new-estimate.js"></script>
|
||||
<script defer src="native-wonga-tools.js"></script>
|
||||
<script defer src="native-estimate-print.js"></script>
|
||||
<script defer src="native-xml-check.js"></script>
|
||||
@@ -93,6 +98,7 @@
|
||||
<hr />
|
||||
<button data-action="save">저장하기 Ctrl+S</button
|
||||
><button data-action="save-as">다른 이름으로 저장</button
|
||||
><button data-action="save-new-estimate">새내역서 파일로저장</button
|
||||
><button data-action="sample">REA 샘플 열기</button>
|
||||
<hr />
|
||||
<button data-action="csv">현재 자료 CSV 내보내기</button
|
||||
@@ -139,7 +145,12 @@
|
||||
><button data-action="block-load-file">블럭 파일에서 붙여넣기</button
|
||||
><button data-action="cell-copy">셀복사</button
|
||||
><button data-action="cell-paste">셀붙여넣기</button
|
||||
><button data-action="schedule-mark">선택 행 예정공정표에 표시/해제</button>
|
||||
><button data-action="schedule-mark">선택 행 예정공정표에 표시/해제</button
|
||||
><button data-action="data-link">하위데이타 연결/끊기</button
|
||||
><button data-action="sum-index">SUM 색인지정</button
|
||||
><button data-action="cost-part">비목별 단가 불러오기(일위대가)</button
|
||||
><button data-action="global-var">전역변수 지정,해제</button
|
||||
><button data-action="global-var-list">전역변수 목록</button>
|
||||
<hr />
|
||||
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
|
||||
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
|
||||
@@ -241,6 +252,8 @@
|
||||
><button data-view="machine-change">기계경비 설계변경</button
|
||||
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
|
||||
><button data-view="escalation">물가변동</button
|
||||
><button data-action="es-info">ES 정보</button
|
||||
><button data-action="jdl-compare">JDL엑셀과 단가비교</button
|
||||
><button data-view="annual">연차공사</button><button data-view="review">계약심사</button
|
||||
><button data-view="resources">동원인원·자재·장비</button>
|
||||
<button data-view="schedule">예정공정표</button>
|
||||
|
||||
@@ -612,7 +612,7 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
|
||||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
|
||||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
|
||||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
|
||||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state, node)) ||
|
||||
(typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) ||
|
||||
(typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ||
|
||||
(typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state)) ||
|
||||
@@ -631,7 +631,12 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstDocForms !== "undefined" && EstDocForms.handle(name, state, node)) ||
|
||||
(typeof EstBlockFile !== "undefined" && EstBlockFile.handle(name, state)) ||
|
||||
(typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state)) ||
|
||||
(typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state))
|
||||
(typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state)) ||
|
||||
(typeof EstRowLinks !== "undefined" && EstRowLinks.handle(name, state)) ||
|
||||
(typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) ||
|
||||
(typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) ||
|
||||
(typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) ||
|
||||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "insert-annual") {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 원본 「ES 정보」(ES도구 mnuESInfo → TfrmESInfo 0x10b838c) — 조달청 물가변동(ES) 제출용 기준시점·비교시점·최종기성일·
|
||||
// 선급금지급일·선급금지급액. 원본은 작업 폴더 ProjectDataInfo.Ini [EscInfo] SDATE·CDATE·ACDATE·PreDATE·PreCost 에 두고
|
||||
// 날짜 기본값은 오늘. 웹은 project.esInfo 에 둔다(물가변동 계산과는 직접 잇지 않음 — 원본도 ES 제출 파일 작성에만 씀).
|
||||
const EstEsInfo = (() => {
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
function dialog(state) {
|
||||
const e = state.project.esInfo || {};
|
||||
EstActions.form(
|
||||
"ES 정보",
|
||||
[
|
||||
{ name: "SDATE", label: "기준시점", type: "date", value: e.SDATE || today() },
|
||||
{ name: "CDATE", label: "비교시점", type: "date", value: e.CDATE || today() },
|
||||
{ name: "ACDATE", label: "최종기성일", type: "date", value: e.ACDATE || today() },
|
||||
{ name: "PreDATE", label: "선급금지급일", type: "date", value: e.PreDATE || today() },
|
||||
{ name: "PreCost", label: "선급금지급액(원)", type: "number", value: e.PreCost ?? 0 },
|
||||
],
|
||||
(values) => {
|
||||
if (!Number.isFinite(values.PreCost) || values.PreCost < 0)
|
||||
throw Error("선급금지급액은 0 이상 수치입니다.");
|
||||
EstApp.change((project) => (project.esInfo = { ...values }), false);
|
||||
},
|
||||
);
|
||||
}
|
||||
function handle(name, state) {
|
||||
if (name !== "es-info") return false;
|
||||
dialog(state);
|
||||
return true;
|
||||
}
|
||||
return { handle };
|
||||
})();
|
||||
@@ -299,5 +299,16 @@ const EstExcelMap = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
return { fields, columnIndex, columnName, records, rows, insert, load, shift, handle };
|
||||
return {
|
||||
fields,
|
||||
columnIndex,
|
||||
columnName,
|
||||
records,
|
||||
rows,
|
||||
insert,
|
||||
load,
|
||||
shift,
|
||||
sheetTables,
|
||||
handle,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 원본 단가산출 「전역변수 지정,해제」(mnuCostSetGlobalVar 0xf053cc)·「전역변수 목록」(mnuCostGlobalvarList → TfrmGlobalVarList).
|
||||
// 지정·해제: 마지막으로 누른 산출근거 칸의 커서 아래 낱말([0-9A-Za-z_], 영문자 하나 이상)을 ⟦전역⟧ 표지로 감싸거나 푼다.
|
||||
// 목록: 단가산출을 처음부터 계산해 전역변수 대입마다 변수명·값과 그 줄로 이동.
|
||||
const EstGlobalVars = (() => {
|
||||
const MARK = "⟦전역⟧";
|
||||
let last = null;
|
||||
if (typeof document !== "undefined" && document.addEventListener)
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const t = event.target.closest?.('[data-table="CostMainDat"][data-field="LineData"]');
|
||||
if (t) last = t;
|
||||
});
|
||||
|
||||
function toggle(text, caret) {
|
||||
const word = /[0-9A-Za-z_]/;
|
||||
let a = caret,
|
||||
b = caret;
|
||||
while (a > 0 && word.test(text[a - 1])) a--;
|
||||
while (b < text.length && word.test(text[b])) b++;
|
||||
const name = text.slice(a, b);
|
||||
if (!name || !/[A-Za-z]/.test(name))
|
||||
throw Error(
|
||||
'선택한 변수는 전역변수로 지정할수 없습니다.\n\n변수는 알파벳과 숫자,"_"로 이루어져야합니다.\n\n예) Q , V1 , L_1',
|
||||
);
|
||||
const before = text.slice(0, a),
|
||||
after = text.slice(b);
|
||||
if (before.endsWith(MARK) && after.startsWith(MARK))
|
||||
return before.slice(0, -MARK.length) + name + after.slice(MARK.length);
|
||||
return `${before}${MARK}${name}${MARK}${after}`;
|
||||
}
|
||||
|
||||
function list(state) {
|
||||
const out = EstText.calculate(state.project.tables.CostMainDat || [], state.project.options);
|
||||
const { esc, number } = EstApp;
|
||||
EstApp.showDialog(
|
||||
"전역변수 목록",
|
||||
out.globals.length
|
||||
? `<table class="sheet"><thead><tr><th>변수명</th><th>값</th><th>이동</th></tr></thead><tbody>${out.globals.map((g) => `<tr><td>${esc(g.name)}</td><td>${number(g.value)}</td><td><button data-action="global-var-go" data-row="${g.index}">이동</button></td></tr>`).join("")}</tbody></table>`
|
||||
: "<p>전역변수가 없습니다. 단가산출 산출근거의 변수에 커서를 두고 「전역변수 지정,해제」를 누르세요.</p>",
|
||||
);
|
||||
}
|
||||
|
||||
function handle(name, state, node) {
|
||||
if (name === "global-var") {
|
||||
if (!last) throw Error("단가산출 산출근거 칸의 변수에 커서를 두세요.");
|
||||
const index = Number(last.dataset.row),
|
||||
text = last.value,
|
||||
next = toggle(text, last.selectionStart ?? 0);
|
||||
EstApp.change(
|
||||
(project) => (project.tables.CostMainDat[index].LineData = EstText.encode(next)),
|
||||
);
|
||||
} else if (name === "global-var-list") list(state);
|
||||
else if (name === "global-var-go") {
|
||||
EstApp.byId("dialog").close();
|
||||
const index = Number(node.dataset.row);
|
||||
state.view = "CostMainDat";
|
||||
const rows = state.project.tables.CostMainDat;
|
||||
let head = index;
|
||||
while (head > 0 && !(Number(rows[head].Index) > 0)) head--;
|
||||
state.group = rows[head]?.Code || state.group;
|
||||
state.selection.clear();
|
||||
state.selection.add(index);
|
||||
EstApp.render();
|
||||
document
|
||||
.querySelector(`[data-table="CostMainDat"][data-row="${index}"][data-field="LineData"]`)
|
||||
?.scrollIntoView({ block: "center" });
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
return { toggle, handle };
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
// 원본 「JDLExcel과 단가비교」(ActionJDLCompare, 숨은 단축키 Ctrl+Alt+J → TfrmJDLExcelCompare) — 일위대가·단가산출·기계경비 호표마다
|
||||
// Excel 의 코드 열에서 그 코드를 품은 셀(Pos(코드, 셀) > 0)을 1행~(호표 수+50)행 안에서 찾아 같은 행 합계 열 값과 TC 를 비교한다.
|
||||
// 차이(|TC − Excel|)가 있으면 표시, 못 찾으면 「없음」. 자료는 바꾸지 않는다(보기 전용). Excel 은 .xlsx·.xls 모두.
|
||||
const EstJdlCompare = (() => {
|
||||
const TABLES = [
|
||||
["HeadTitleDat", "일위대가"],
|
||||
["CostTitleDat", "단가산출"],
|
||||
["MachTitleDat", "기계경비"],
|
||||
];
|
||||
function compare(rows, table, codeCol, totalCol) {
|
||||
const limit = rows.length + 50;
|
||||
return rows
|
||||
.filter((r) => String(r.Code ?? "").trim())
|
||||
.map((r) => {
|
||||
const code = String(r.Code).trim();
|
||||
let found = null;
|
||||
for (let i = 0; i < Math.min(limit, table.length); i++) {
|
||||
const cell = table[i]?.[codeCol];
|
||||
if (cell != null && String(cell).includes(code)) {
|
||||
found = Number(String(table[i][totalCol] ?? "").replace(/,/g, "")) || 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tc = Number(r.TC) || 0;
|
||||
return {
|
||||
Code: code,
|
||||
Name: r.Name ?? "",
|
||||
Size: r.Size ?? "",
|
||||
Unite: r.Unite ?? "",
|
||||
TC: tc,
|
||||
OTC: found,
|
||||
GN: found == null ? null : Math.abs(tc - found),
|
||||
Checked: found == null || Math.abs(tc - found) > 1e-9,
|
||||
};
|
||||
});
|
||||
}
|
||||
// .xls(BIFF) 셀 → 행 배열
|
||||
function xlsTables(sheets) {
|
||||
const out = {};
|
||||
for (const s of sheets) {
|
||||
const table = [];
|
||||
for (const [k, c] of Object.entries(s.cells)) {
|
||||
const [r, col] = k.split(",").map(Number);
|
||||
(table[r] ||= [])[col] = c.value;
|
||||
}
|
||||
out[s.name] = table;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function form(state) {
|
||||
const c = (state.jdlCompare ||= {
|
||||
tab: 0,
|
||||
sheet: 1,
|
||||
code: "A",
|
||||
total: "H",
|
||||
tables: null,
|
||||
result: [],
|
||||
}),
|
||||
{ esc, number } = EstApp,
|
||||
diff = c.result.filter((r) => r.Checked).length;
|
||||
EstApp.showDialog(
|
||||
"JDL엑셀 단가 비교",
|
||||
`<div class="business-tools">${TABLES.map(([, l], i) => `<button data-action="jdl-compare-tab" data-i="${i}"${i === c.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><div class="form-fields"><label>시트번호<input name="jc-sheet" type="number" min="1" value="${c.sheet}"></label><label>코드칼럼<input name="jc-code" value="${esc(c.code)}" size="3"></label><label>합계셀칼럼<input name="jc-total" value="${esc(c.total)}" size="3"></label></div><button data-action="jdl-compare-open">엑셀파일열기</button> <button data-action="jdl-compare-run">검 색</button> ${c.tables ? `시트 ${Object.keys(c.tables).length}개` : "Excel 을 여세요"} · 다른 금액·없음 ${diff}건<div style="max-height:380px;overflow:auto"><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>EST 금액</th><th>Excel 금액</th><th>차이</th></tr></thead><tbody>${c.result.map((r) => `<tr${r.Checked ? ' style="background:#fde2e2"' : ""}><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td>${number(r.TC)}</td><td>${r.OTC == null ? "없음" : number(r.OTC)}</td><td>${r.GN == null ? "" : number(r.GN)}</td></tr>`).join("")}</tbody></table></div>`,
|
||||
);
|
||||
}
|
||||
function read(c) {
|
||||
const v = (n) => document.querySelector(`#dialog-content [name="${n}"]`)?.value;
|
||||
c.sheet = Math.max(1, Number(v("jc-sheet")) || 1);
|
||||
c.code = String(v("jc-code") || "A").toUpperCase();
|
||||
c.total = String(v("jc-total") || "H").toUpperCase();
|
||||
}
|
||||
async function run(name, state, node) {
|
||||
if (name === "jdl-compare") return form(state);
|
||||
const c = state.jdlCompare;
|
||||
read(c);
|
||||
if (name === "jdl-compare-tab") {
|
||||
c.tab = Number(node.dataset.i);
|
||||
c.result = [];
|
||||
} else if (name === "jdl-compare-open") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".xlsx,.xls";
|
||||
input.addEventListener("change", async () => {
|
||||
try {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const body = await file.arrayBuffer();
|
||||
c.tables = /\.xls$/i.test(file.name)
|
||||
? xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
|
||||
: EstExcelMap.sheetTables(
|
||||
await EstApp.api("/api/import-xlsx", { method: "POST", body }),
|
||||
);
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
}
|
||||
form(state);
|
||||
});
|
||||
return input.click();
|
||||
} else if (name === "jdl-compare-run") {
|
||||
if (!c.tables) throw Error("엑셀파일을 먼저 여세요.");
|
||||
const table = Object.values(c.tables)[c.sheet - 1];
|
||||
if (!table) throw Error(`시트 ${c.sheet} 이 없습니다.`);
|
||||
const col = (x) => EstExcelMap.columnIndex(x);
|
||||
c.result = compare(
|
||||
state.project.tables[TABLES[c.tab][0]] || [],
|
||||
table,
|
||||
col(c.code),
|
||||
col(c.total),
|
||||
);
|
||||
}
|
||||
form(state);
|
||||
}
|
||||
const names = new Set(["jdl-compare", "jdl-compare-tab", "jdl-compare-open", "jdl-compare-run"]);
|
||||
function handle(name, state, node) {
|
||||
if (!names.has(name)) return false;
|
||||
run(name, state, node).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return { compare, xlsTables, handle };
|
||||
})();
|
||||
@@ -18,16 +18,25 @@ const EstMultiSearch = (() => {
|
||||
const rows = saved.hits
|
||||
.map(
|
||||
(h, i) =>
|
||||
`<tr><td><input type="checkbox" name="ms-hit" value="${i}"></td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
|
||||
`<tr><td><input type="checkbox" name="ms-hit" value="${i}" ${saved.picked?.has(i) ? "checked" : ""}>${[103, 104, 105].includes(h.kind) ? `<button data-action="multi-search-detail" data-i="${i}" title="근거자료보기">근거</button>` : ""}</td><td>${esc(h.project)}</td><td>${esc(h.label)}</td><td>${esc(h.Code)}</td><td style="text-align:left">${esc(h.Name)}</td><td>${esc(h.Size)}</td><td>${esc(h.Unite)}</td><td style="text-align:right">${EstApp.number(h.price)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
EstApp.showDialog(
|
||||
"통합 검색",
|
||||
`<p>검색위치: 이 앱에 저장된 다른 작업</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
|
||||
`<p>검색위치: <label><input type="radio" name="ms-where" value="all" ${saved.where !== "main" ? "checked" : ""}>저장된 모든 작업</label> <label><input type="radio" name="ms-where" value="main" ${saved.where === "main" ? "checked" : ""}>설계참고철(Main Data)</label> <button data-action="main-list">설계참고철 관리</button> · 검색어에 「10000~20000」처럼 쓰면 단가 범위</p><div class="form-fields">${kinds.map(([k, label]) => `<label><input type="checkbox" name="ms-kind" value="${k}" ${saved.kinds.includes(k) ? "checked" : ""}>${label}</label>`).join("")}<label>검색어<input name="ms-query" value="${esc(saved.query)}"></label></div><button data-action="multi-search-run">검색</button> <button data-action="multi-search-take">고른 자료를 이 공사로 가져오기</button><div style="max-height:360px;overflow:auto;border:1px solid #ccc;margin-top:6px"><table class="sheet" style="font-size:12px"><thead><tr><th></th><th>작업</th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${rows}</tbody></table></div><p>찾은 자료 ${saved.total}건${saved.total > saved.hits.length ? ` 중 앞 ${saved.hits.length}건` : ""}</p>`,
|
||||
);
|
||||
}
|
||||
|
||||
async function run(name, state) {
|
||||
async function run(name, state, node) {
|
||||
if (name === "multi-search-detail") return detail(state, Number(node.dataset.i));
|
||||
if (name === "multi-search-pick")
|
||||
(state.multiSearch.picked ||= new Set()).add(Number(node.dataset.i));
|
||||
if (name === "multi-search-pick" || name === "multi-search-back") return form(state);
|
||||
if (name === "main-list") return mainForm(state);
|
||||
if (name === "main-list-save") {
|
||||
await mainSave();
|
||||
return form(state);
|
||||
}
|
||||
if (name === "multi-search") form(state);
|
||||
else if (name === "multi-search-run") {
|
||||
const saved = state.multiSearch;
|
||||
@@ -35,9 +44,17 @@ const EstMultiSearch = (() => {
|
||||
saved.kinds = [...document.querySelectorAll('#dialog-content [name="ms-kind"]:checked')].map(
|
||||
(n) => Number(n.value),
|
||||
);
|
||||
saved.where = document.querySelector('#dialog-content [name="ms-where"]:checked')?.value;
|
||||
const params = new URLSearchParams({ q: saved.query, kinds: saved.kinds.join(",") });
|
||||
if (saved.where === "main") {
|
||||
const ids = await mainList();
|
||||
if (!ids.length)
|
||||
throw Error("설계참고철에 등록한 작업이 없습니다. 설계참고철 관리에서 추가하세요.");
|
||||
params.set("only", ids.join(","));
|
||||
}
|
||||
if (state.project.id) params.set("exclude", state.project.id);
|
||||
Object.assign(saved, await EstApp.api(`/api/search?${params}`));
|
||||
saved.picked = new Set();
|
||||
form(state);
|
||||
} else if (name === "multi-search-take") {
|
||||
const picked = [...document.querySelectorAll('#dialog-content [name="ms-hit"]:checked')].map(
|
||||
@@ -65,10 +82,78 @@ const EstMultiSearch = (() => {
|
||||
EstApp.render();
|
||||
}
|
||||
}
|
||||
const names = new Set(["multi-search", "multi-search-run", "multi-search-take"]);
|
||||
function handle(name, state) {
|
||||
// 설계참고철(TfrmMainTableList 「MAIN DATA 추가 및 삭제」): 저장된 작업 중 최대 10개 — 양식 보관함(종류 options, 이름 「설계참고철」)
|
||||
const MAIN = "설계참고철";
|
||||
async function mainList() {
|
||||
return (
|
||||
(await EstApp.api("/api/forms")).find((f) => f.kind === "options" && f.name === MAIN)?.data
|
||||
?.ids || []
|
||||
);
|
||||
}
|
||||
async function mainForm(state) {
|
||||
const ids = await mainList(),
|
||||
projects = await EstApp.api("/api/projects"),
|
||||
{ esc } = EstApp;
|
||||
EstApp.showDialog(
|
||||
"MAIN DATA(설계참고철) 추가 및 삭제",
|
||||
`<p>통합 검색 「설계참고철」에서 찾을 작업을 고르세요(최대 10개).</p><div style="max-height:360px;overflow:auto">${projects.map((p) => `<label style="display:block"><input type="checkbox" name="ml-id" value="${esc(p.id)}" ${ids.includes(p.id) ? "checked" : ""}>${esc(p.name || p.id)}</label>`).join("")}</div><button data-action="main-list-save">적용</button> <button data-action="multi-search">통합 검색으로</button>`,
|
||||
);
|
||||
}
|
||||
async function mainSave() {
|
||||
const ids = [...document.querySelectorAll('#dialog-content [name="ml-id"]:checked')].map(
|
||||
(n) => n.value,
|
||||
);
|
||||
if (ids.length > 10) throw Error("Main Data는 10개 이상 추가할수없습니다.");
|
||||
await EstApp.api("/api/forms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: "options", name: MAIN, data: { ids } }),
|
||||
});
|
||||
}
|
||||
// 근거자료보기(TfrmFoundDetail): 찾은 일위·단산·기계 호표의 세부 줄과 총액·노무·재료·경비, 「적용」 = 그 결과를 고름
|
||||
const BODY = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" };
|
||||
async function detail(state, i) {
|
||||
remember(state);
|
||||
const hit = state.multiSearch.hits[i],
|
||||
project = await EstApp.api(`/api/projects/${hit.id}`),
|
||||
{ esc, number } = EstApp,
|
||||
rows = project.tables[BODY[hit.kind]] || [],
|
||||
group = EstNative.groups(rows).get(hit.Code);
|
||||
if (!group) throw Error("근거 자료를 찾지 못했습니다.");
|
||||
const head = group.rows[0],
|
||||
body = group.rows
|
||||
.slice(1)
|
||||
.map((r) =>
|
||||
hit.kind === 104
|
||||
? `<tr><td colspan="5" style="text-align:left">${esc(EstText.clean(r.LineData || ""))}</td></tr>`
|
||||
: `<tr><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size)}</td><td>${esc(r.Unite)}</td><td style="text-align:right">${number(r.GN)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
EstApp.showDialog(
|
||||
`[${hit.Name}]의 근거자료보기`,
|
||||
`<p>총 액 : ${number(head.TC)} · 노무비 : ${number(head.NC)} · 재료비 : ${number(head.GC)} · 경 비 : ${number(head.JC)}</p><div style="max-height:360px;overflow:auto"><table class="sheet"><tbody>${body}</tbody></table></div><button data-action="multi-search-pick" data-i="${i}">적 용</button> <button data-action="multi-search-back">돌아가기</button>`,
|
||||
);
|
||||
}
|
||||
function remember(state) {
|
||||
const boxes = document.querySelectorAll('#dialog-content [name="ms-hit"]');
|
||||
if (boxes.length)
|
||||
state.multiSearch.picked = new Set(
|
||||
[...boxes].filter((n) => n.checked).map((n) => Number(n.value)),
|
||||
);
|
||||
}
|
||||
const names = new Set([
|
||||
"multi-search",
|
||||
"multi-search-run",
|
||||
"multi-search-take",
|
||||
"main-list",
|
||||
"main-list-save",
|
||||
"multi-search-detail",
|
||||
"multi-search-pick",
|
||||
"multi-search-back",
|
||||
]);
|
||||
function handle(name, state, node) {
|
||||
if (!names.has(name)) return false;
|
||||
run(name, state).catch((error) => {
|
||||
run(name, state, node).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 원본 「새내역서 파일로저장」(mnuSaveNewEst 0x113384c) — 현재 문서는 그대로 두고 설계변경 흔적을 지운 새 작업을 따로 저장.
|
||||
// 표 12개(노임·자재·견적·표준·기계기준·기계 본문/호표·단산 본문/호표·일위 본문/호표·내역서)의 「_」로 끝나는 칸(당초값)을 비우고
|
||||
// 신규 표시(NewItem)를 끄며, 원가 안전관리비 특례 줄(CalcIndex 4)은 1 로, 당초 사본(baseline)은 뺀다. 재계산 없음.
|
||||
const EstNewEstimate = (() => {
|
||||
const TABLES = [
|
||||
"LaborcDat",
|
||||
"StuffcDat",
|
||||
"EstiMateDat",
|
||||
"SilJukDat",
|
||||
"MachGijunDat",
|
||||
"MachMainDat",
|
||||
"MachTitleDat",
|
||||
"CostMainDat",
|
||||
"CostTitleDat",
|
||||
"HeadMainDat",
|
||||
"HeadTitleDat",
|
||||
"EstMainDat",
|
||||
];
|
||||
function build(project, name) {
|
||||
const next = structuredClone(project);
|
||||
delete next.id;
|
||||
delete next.baseline;
|
||||
next.name = name;
|
||||
for (const table of TABLES)
|
||||
for (const row of next.tables[table] || []) {
|
||||
for (const key of Object.keys(row)) if (key.endsWith("_")) row[key] = null;
|
||||
if ("NewItem" in row) row.NewItem = false;
|
||||
}
|
||||
for (const row of next.tables.Wonga || [])
|
||||
if ((Number(row.CalcIndex) & 0xff) === 4) row.CalcIndex = 1;
|
||||
return next;
|
||||
}
|
||||
async function run(state) {
|
||||
const name = prompt("새 내역서 이름", `${state.project.name || "내역서"} (새 내역서)`)?.trim();
|
||||
if (!name) return;
|
||||
const result = await EstApp.api("/api/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(build(state.project, name)),
|
||||
});
|
||||
EstApp.download(`${result.name}.estweb.json`, JSON.stringify(result, null, 2));
|
||||
state.notice = `새 내역서 「${result.name}」를 따로 저장했습니다. 지금 화면은 그대로입니다.`;
|
||||
EstApp.render();
|
||||
}
|
||||
function handle(name, state) {
|
||||
if (name !== "save-new-estimate") return false;
|
||||
run(state).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return { build, handle };
|
||||
})();
|
||||
@@ -129,7 +129,7 @@ const EstQuantitySheets = (() => {
|
||||
`<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${options}</select></label><input data-quantity-query value="${esc(state.quantityQuery || "")}" placeholder="명칭·규격" aria-label="산출서 검색"><button data-action="quantity-sheet-search">검색</button><button data-action="quantity-sheet-all">전체보기</button><button data-quantity-new>새 산출서</button><button data-action="quantity-sheet-insert">삽입</button><button data-action="quantity-sheet-multi-delete">선택공종 삭제</button><button data-quantity-excel>Excel 셀 수량 연결</button><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
|
||||
}
|
||||
|
||||
function handle(name, state, node) {
|
||||
@@ -153,6 +153,55 @@ const EstQuantitySheets = (() => {
|
||||
state.quantitySheet = first.Code;
|
||||
return true;
|
||||
}
|
||||
if (name === "quantity-sheet-insert") {
|
||||
// 삽입(TFRMMULLANG btnInsert): 현재 산출서 앞에 새 산출서, 공종명칭(품명·규격) 입력
|
||||
const title = prompt("공종명칭", "새 수량산출서");
|
||||
if (title == null) return true;
|
||||
const size = prompt("규격", "") ?? "";
|
||||
EstApp.change((project) => {
|
||||
const sheets = (project.quantitySheets ||= []),
|
||||
at = Math.max(
|
||||
0,
|
||||
sheets.findIndex((s) => s.Code === code),
|
||||
),
|
||||
next = `Q${EstApp.uid()}`;
|
||||
sheets.splice(at, 0, {
|
||||
Code: next,
|
||||
Name: title.trim() || "새 수량산출서",
|
||||
Size: size.trim(),
|
||||
rows: [],
|
||||
items: [],
|
||||
options: { digits: 2, roundMode: 2 },
|
||||
});
|
||||
state.quantitySheet = next;
|
||||
}, false);
|
||||
return true;
|
||||
}
|
||||
if (name === "quantity-sheet-multi-delete") {
|
||||
// 선택공종 삭제(btnSelectDel): 고른 산출서들을 한 번에 지움
|
||||
const { esc } = EstApp;
|
||||
EstApp.showDialog(
|
||||
"선택공종 삭제",
|
||||
`<div style="max-height:360px;overflow:auto">${(state.project.quantitySheets || []).map((s) => `<label style="display:block"><input type="checkbox" name="qd-sheet" value="${esc(s.Code)}">${esc(s.Name || s.Code)} ${esc(s.Size || "")}</label>`).join("")}</div><button data-action="quantity-sheet-multi-delete-ok">삭제</button>`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (name === "quantity-sheet-multi-delete-ok") {
|
||||
const codes = new Set(
|
||||
[...document.querySelectorAll('#dialog-content [name="qd-sheet"]:checked')].map(
|
||||
(n) => n.value,
|
||||
),
|
||||
);
|
||||
if (!codes.size) throw Error("지울 산출서를 고르세요.");
|
||||
if (!confirm(`산출서 ${codes.size}개를 삭제하시겠습니까?`)) return true;
|
||||
EstApp.byId("dialog").close();
|
||||
EstApp.change((project) => {
|
||||
project.quantitySheets = project.quantitySheets.filter((s) => !codes.has(s.Code));
|
||||
if (codes.has(state.quantitySheet))
|
||||
state.quantitySheet = project.quantitySheets[0]?.Code || "manual";
|
||||
}, false);
|
||||
return true;
|
||||
}
|
||||
if (name === "quantity-sheet-copy") {
|
||||
EstApp.change((project) => {
|
||||
state.quantitySheet = copy(project, code, EstApp.uid());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 원본 줄 단위 연결 도구(spec_part 1~3):
|
||||
// 비목별 단가 불러오기(TfrmHead popupCostNC·GC·JC·All 0xf1b54c) — 일위대가 줄의 하위 자료(견적·일위·단산·기계)에서 한 비목 단가만
|
||||
// 남기고 나머지 단가 0, 기준비목(OCheck) := 고른 비목(0 전체·1 재료·2 노무·3 경비). 노임·자재·표준 줄은 거부.
|
||||
// SUM 색인지정(popMainSumIndex, TfrmSetSumIndex): 고른 줄 SKind := 해제(0)·A(1)…Z(26) — 비율 줄(62)이 코드 글자로 모아 더함.
|
||||
// 하위데이타 연결/끊기(mnuDataLink 0xf00e6c): 연결 줄은 끊고(LinkIndex 0, 값은 남김), 끊긴 줄은 일위→단산→기계→표준→견적→자재→노임
|
||||
// 차례로 코드(없으면 품명·규격·단위 맞춤)를 찾아 코드·명칭·규격·단위·단가를 가져와 연결. 원본은 초점 한 줄, 웹은 고른 줄 모두.
|
||||
const EstRowLinks = (() => {
|
||||
const num = (v) => Number(v) || 0;
|
||||
const SRC = {
|
||||
101: "EstiMateDat",
|
||||
103: "HeadTitleDat",
|
||||
104: "CostTitleDat",
|
||||
105: "MachTitleDat",
|
||||
21: "SilJukDat",
|
||||
5: "StuffcDat",
|
||||
6: "LaborcDat",
|
||||
};
|
||||
const ORDER = [103, 104, 105, 21, 101, 5, 6];
|
||||
const LINKED = new Set([5, 6, 21, 101, 103, 104, 105, 211, 212, 213, 214, 215]);
|
||||
const norm = (s) => String(s ?? "").replace(/\s/g, "");
|
||||
function byCode(project, kind, code) {
|
||||
const c = norm(code);
|
||||
return c ? (project.tables[SRC[kind]] || []).find((r) => norm(r.Code) === c) : null;
|
||||
}
|
||||
|
||||
function costPart(project, row, tag) {
|
||||
const kind = num(row.LinkIndex);
|
||||
if ([5, 6, 21].includes(kind)) throw Error("노임,자재,표준시장단가는 적용할수 없습니다.");
|
||||
const src = SRC[kind] ? byCode(project, kind, row.Code) : null,
|
||||
v = (k) => num(src?.[k]);
|
||||
row.OCheck = tag;
|
||||
row.NU = row.GU = row.JU = row.SU = 0;
|
||||
if (tag === 0) Object.assign(row, { NU: v("NC"), GU: v("GC"), JU: v("JC"), SU: v("SC") });
|
||||
else if (tag === 2) row.NU = v("NC");
|
||||
else if (tag === 1) row.GU = v("GC");
|
||||
else Object.assign(row, { JU: v("JC"), SU: v("SC") });
|
||||
}
|
||||
|
||||
function sumIndex(view, row, k) {
|
||||
if (view === "EstMainDat") {
|
||||
if (num(row.Level) > 0 && !num(row.GN))
|
||||
throw Error("레벨지정한 라인의 값을 더할값으로 지정할수없습니다.");
|
||||
if ([52, 53, 55, 57].includes(num(row.LinkIndex)) || num(row.Index) !== 0)
|
||||
throw Error("현재라인의 값은 더할값으로 지정할수없습니다.");
|
||||
} else if (num(row.LinkIndex) === 53 || num(row.Index) > 0)
|
||||
throw Error("현재라인의 값은 더할값으로 지정할수없습니다.");
|
||||
row.SKind = k;
|
||||
}
|
||||
|
||||
function toggle(project, view, row) {
|
||||
const li = num(row.LinkIndex);
|
||||
if (li) {
|
||||
if (LINKED.has(li)) row.LinkIndex = 0;
|
||||
return li && LINKED.has(li) ? "unlink" : "skip";
|
||||
}
|
||||
const tag = { EstMainDat: 1, HeadMainDat: 2, MachMainDat: 4 }[view] ?? 0;
|
||||
for (const kind of ORDER) {
|
||||
const src = norm(row.Code)
|
||||
? byCode(project, kind, row.Code)
|
||||
: EstAutoLink.find(project, kind, EstAutoLink.key(row, tag));
|
||||
if (!src) continue;
|
||||
Object.assign(row, {
|
||||
Code: src.Code,
|
||||
Name: src.Name ?? "",
|
||||
Size: src.Size ?? "",
|
||||
Unite: src.Unite ?? "",
|
||||
LinkIndex: kind,
|
||||
NU: num(src.NC),
|
||||
GU: num(src.GC),
|
||||
JU: num(src.JC),
|
||||
SU: num(src.SC),
|
||||
});
|
||||
if (kind === 5) row.GU = num(src.TC);
|
||||
if (kind === 6) row.NU = num(src.TC);
|
||||
row.TU = row.NU + row.GU + row.JU + row.SU;
|
||||
return "link";
|
||||
}
|
||||
throw Error("해당 하위data를 찾지못해 연결시키지 못했습니다.");
|
||||
}
|
||||
|
||||
function picked(state, views) {
|
||||
const rows = state.project.tables[state.view] || [],
|
||||
list = [...state.selection].filter((i) => rows[i]).sort((a, b) => a - b);
|
||||
if (!views.includes(state.view) || !list.length)
|
||||
throw Error(
|
||||
`${views.length > 2 ? "내역서·일위대가·단가산출·기계경비" : "내역서·일위대가"}에서 줄을 고르세요.`,
|
||||
);
|
||||
return list;
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
if (name === "cost-part") {
|
||||
if (state.view !== "HeadMainDat") throw Error("일위대가 화면에서 쓰세요.");
|
||||
const list = picked(state, ["HeadMainDat"]);
|
||||
EstActions.form(
|
||||
"비목별 단가 불러오기",
|
||||
[
|
||||
{
|
||||
name: "tag",
|
||||
label: "불러올 비목",
|
||||
value: 0,
|
||||
choices: [
|
||||
[0, "전체비목 불러오기"],
|
||||
[2, "노무비만 불러오기"],
|
||||
[1, "재료비만 불러오기"],
|
||||
[3, "경비만 불러오기"],
|
||||
],
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
const probe = structuredClone(state.project);
|
||||
list.forEach((i) => costPart(probe, probe.tables.HeadMainDat[i], Number(values.tag)));
|
||||
EstApp.change((project) =>
|
||||
list.forEach((i) =>
|
||||
costPart(project, project.tables.HeadMainDat[i], Number(values.tag)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (name === "sum-index") {
|
||||
const list = picked(state, ["EstMainDat", "HeadMainDat"]);
|
||||
EstActions.form(
|
||||
"Sum 색인",
|
||||
[
|
||||
{
|
||||
name: "k",
|
||||
label: "색인",
|
||||
value: 1,
|
||||
choices: [
|
||||
[0, "해제"],
|
||||
...Array.from({ length: 26 }, (_, i) => [i + 1, String.fromCharCode(65 + i)]),
|
||||
],
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
const rows = state.project.tables[state.view];
|
||||
list.forEach((i) => sumIndex(state.view, structuredClone(rows[i]), 0));
|
||||
EstApp.change((project) =>
|
||||
list.forEach((i) =>
|
||||
sumIndex(state.view, project.tables[state.view][i], Number(values.k)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (name === "data-link") {
|
||||
const list = picked(state, ["EstMainDat", "HeadMainDat", "CostMainDat", "MachMainDat"]);
|
||||
const probe = structuredClone(state.project);
|
||||
list.forEach((i) => toggle(probe, state.view, probe.tables[state.view][i]));
|
||||
let out = [];
|
||||
EstApp.change((project) => {
|
||||
out = list.map((i) => toggle(project, state.view, project.tables[state.view][i]));
|
||||
});
|
||||
const linked = out.filter((r) => r === "link").length,
|
||||
unlinked = out.filter((r) => r === "unlink").length;
|
||||
state.notice = `연결 ${linked}줄 · 끊기 ${unlinked}줄`;
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
return { costPart, sumIndex, toggle, handle };
|
||||
})();
|
||||
@@ -1,4 +1,6 @@
|
||||
// REA f2a1fc/f254c8: LineData 표지와 일반·성분·마지막 합계의 정밀도.
|
||||
// 전역변수(spec_cost_tools §2 · 0x93c22c): \x05이름\x05 표지로 대입한 변수는 호표가 바뀌어도 남아(전역표) 다른 호표에서 표지 없이 써도 찾는다.
|
||||
// 찾는 차례 = 그 호표 변수 → 전역표. 표지는 계산 동안 떼고 계산 뒤 같은 낱말에 다시 붙인다.
|
||||
const EstText = (() => {
|
||||
const { num, components, floating, sum } = EstMath;
|
||||
const marker = /\x12([-\d.,]+)\x12/;
|
||||
@@ -6,7 +8,7 @@ const EstText = (() => {
|
||||
const clean = (s) =>
|
||||
String(s || "")
|
||||
.replace(/[\x06\x07][^\x06\x07]*[\x06\x07]/g, "")
|
||||
.replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, "");
|
||||
.replace(/[\x03\x05\x0c\x0e\x11\x12\x13\x15\x1a]/g, "");
|
||||
const numeric = (s) => Number(String(s).replaceAll(",", ""));
|
||||
const sumCurrency = (values) =>
|
||||
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
|
||||
@@ -56,8 +58,10 @@ const EstText = (() => {
|
||||
titles = [],
|
||||
errors = [],
|
||||
checks = [];
|
||||
const globals = Object.create(null),
|
||||
globalList = [];
|
||||
let head = null,
|
||||
variables = Object.create(null),
|
||||
variables = Object.create(globals),
|
||||
changed = new Map(),
|
||||
currentSource = null,
|
||||
finalOverride = null,
|
||||
@@ -94,7 +98,7 @@ const EstText = (() => {
|
||||
if (num(row.Index) > 0) {
|
||||
finish();
|
||||
head = row;
|
||||
variables = Object.create(null);
|
||||
variables = Object.create(globals);
|
||||
changed = new Map();
|
||||
currentSource = null;
|
||||
finalOverride = null;
|
||||
@@ -170,7 +174,10 @@ const EstText = (() => {
|
||||
errors.push(`${head.Code}:${index + 1} ${error.message}`);
|
||||
}
|
||||
}
|
||||
let text = original.replace(/\{([+-])\}/g, "$1");
|
||||
const globalNames = [...original.matchAll(/\x05([A-Za-z][A-Za-z0-9_]*)\x05/g)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
let text = original.replace(/\x05/g, "").replace(/\{([+-])\}/g, "$1");
|
||||
let selectionError = "";
|
||||
if (num(row.LinkIndex) === 62) {
|
||||
text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => {
|
||||
@@ -599,16 +606,28 @@ const EstText = (() => {
|
||||
errors.push(`${head.Code}:${index + 1} ${error.message}`);
|
||||
row.Error = error.message;
|
||||
}
|
||||
for (const name of globalNames) {
|
||||
if (Object.hasOwn(variables, name)) {
|
||||
globals[name] = variables[name];
|
||||
globalList.push({ name, value: variables[name], index });
|
||||
}
|
||||
if (!String(row.LineData).includes(`\x05${name}\x05`))
|
||||
row.LineData = String(row.LineData).replace(
|
||||
new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`),
|
||||
`\x05${name}\x05`,
|
||||
);
|
||||
}
|
||||
if (!hasAutoTotals && component[sc])
|
||||
plainTotals[component[sc]] = num(plainTotals[component[sc]]) + num(row.TC);
|
||||
}
|
||||
finish();
|
||||
return { rows, titles, errors, checks };
|
||||
return { rows, titles, errors, checks, globals: globalList };
|
||||
}
|
||||
const names = {
|
||||
"\x06": "구간지정",
|
||||
"\x07": "구간합계",
|
||||
"\x03": "단가",
|
||||
"\x05": "전역",
|
||||
"\x0c": "다른줄",
|
||||
"\x0e": "품명",
|
||||
"\x11": "변수",
|
||||
@@ -620,7 +639,7 @@ const EstText = (() => {
|
||||
};
|
||||
const display = (text) =>
|
||||
String(text || "").replace(
|
||||
/[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
|
||||
/[\x03\x05\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
|
||||
(ch) => `⟦${names[ch]}⟧`,
|
||||
);
|
||||
const encode = (text) =>
|
||||
|
||||
@@ -19,12 +19,14 @@ const fold = (value) =>
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
|
||||
function search(directory, query, kinds, exclude = "") {
|
||||
// only = 설계참고철(Main Data) 작업 id 목록 — 있으면 그 작업만. 검색어가 「a~b」 또는 숫자 하나면 단가(TC) 범위·같음(원본 0xefefb0).
|
||||
function search(directory, query, kinds, exclude = "", only = null) {
|
||||
const words = String(query ?? "")
|
||||
.split(/\s+/)
|
||||
.map(fold)
|
||||
.filter(Boolean);
|
||||
if (!words.length) throw Error("검색어를 입력하세요.");
|
||||
const range = /^(\d+(?:\.\d+)?)(?:~(\d+(?:\.\d+)?))?$/.exec(String(query).replace(/[\s,]/g, ""));
|
||||
const wanted = (kinds?.length ? kinds : Object.keys(sources))
|
||||
.map(Number)
|
||||
.filter((k) => sources[k]);
|
||||
@@ -34,7 +36,7 @@ function search(directory, query, kinds, exclude = "") {
|
||||
let total = 0;
|
||||
for (const name of fs.readdirSync(directory).filter((n) => /^[a-f0-9]{24}\.json$/.test(n))) {
|
||||
const id = name.slice(0, -5);
|
||||
if (id === exclude) continue;
|
||||
if (id === exclude || (only && !only.includes(id))) continue;
|
||||
let project;
|
||||
try {
|
||||
project = JSON.parse(fs.readFileSync(path.join(directory, name), "utf8"));
|
||||
@@ -45,8 +47,15 @@ function search(directory, query, kinds, exclude = "") {
|
||||
const [table, label] = sources[kind];
|
||||
for (const row of project.tables?.[table] || []) {
|
||||
if (!row?.Code) continue;
|
||||
const text = fold(`${row.Code} ${row.Name} ${row.Size}`);
|
||||
if (!words.every((w) => text.includes(w))) continue;
|
||||
if (range) {
|
||||
const tc = Number(row.TC) || 0,
|
||||
lo = Number(range[1]),
|
||||
hi = range[2] == null ? lo : Number(range[2]);
|
||||
if (tc < Math.min(lo, hi) || tc > Math.max(lo, hi)) continue;
|
||||
} else {
|
||||
const text = fold(`${row.Code} ${row.Name} ${row.Size}`);
|
||||
if (!words.every((w) => text.includes(w))) continue;
|
||||
}
|
||||
total++;
|
||||
if (hits.length < LIMIT)
|
||||
hits.push({
|
||||
|
||||
@@ -5,6 +5,7 @@ const http = require("node:http"),
|
||||
vm = require("node:vm"),
|
||||
crypto = require("node:crypto");
|
||||
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
|
||||
const { readXls } = require("./xls-read.cjs");
|
||||
const exchange = require("./exchange.cjs");
|
||||
const { exportEstx } = require("./export-estx.cjs");
|
||||
const jdl = require("./jdl.cjs");
|
||||
@@ -81,6 +82,11 @@ const allowed = new Set([
|
||||
"native-block-file.js",
|
||||
"native-display.js",
|
||||
"native-cell-clip.js",
|
||||
"native-row-links.js",
|
||||
"native-es-info.js",
|
||||
"native-jdl-compare.js",
|
||||
"native-global-vars.js",
|
||||
"native-new-estimate.js",
|
||||
"native-wonga-tools.js",
|
||||
"native-estimate-print.js",
|
||||
"native-xml-check.js",
|
||||
@@ -200,6 +206,7 @@ const server = http.createServer(async (req, res) => {
|
||||
url.searchParams.get("q"),
|
||||
kinds,
|
||||
url.searchParams.get("exclude") || "",
|
||||
url.searchParams.get("only") ? url.searchParams.get("only").split(",") : null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -242,6 +249,10 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, importEstx(bytes, name));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/read-xls") {
|
||||
reply(res, 200, readXls(await body(req)));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/pps-table") {
|
||||
// 조달청 코드 목록(원본 System\MainSys\PPS_STUFFC.dat · PPS_HEAD.dat, DBISAM) — 코드·명칭·규격·단위만
|
||||
const rows = dbisamTable(await body(req), null, []);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ui_locales_b3 } from "./ui_template_locale_b3";
|
||||
import { ui_locales_e04 } from "./ui_template_locale_e04";
|
||||
import { ui_locales_b5 } from "./ui_template_locale_b5";
|
||||
import { ui_locales_m1 } from "./ui_template_locale_m1";
|
||||
import { ui_locales_machine } from "./ui_template_locale_machine";
|
||||
import { ui_locales_m2 } from "./ui_template_locale_m2";
|
||||
import { ui_locales_m2_judge } from "./ui_template_locale_m2_judge";
|
||||
import { ui_locales_de } from "./ui_template_locale_de";
|
||||
@@ -77,6 +78,7 @@ export const ui_locales = {
|
||||
...ui_locales_e04,
|
||||
...ui_locales_b5,
|
||||
...ui_locales_m1,
|
||||
...ui_locales_machine,
|
||||
...ui_locales_m2,
|
||||
...ui_locales_m2_judge,
|
||||
...ui_locales_de,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* ui_template_locale_machine.ts
|
||||
* 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에.
|
||||
* ========================================================================== */
|
||||
|
||||
export const ui_locales_machine = {
|
||||
MF_Name: ["이름", "Name"],
|
||||
MF_Spec: ["규격", "Spec"],
|
||||
MF_Price: ["가격", "Price"],
|
||||
MF_Coef: ["손료계수", "Ownership coef."],
|
||||
MF_CoefDirect: ["바로 넣기", "Enter directly"],
|
||||
MF_CoefPumsem: ["품셈 방식", "By standard"],
|
||||
MF_Life: ["내용시간", "Service life"],
|
||||
MF_Yearly: ["연간표준가동시간", "Annual hours"],
|
||||
MF_Dep: ["상각비율", "Depreciation ratio"],
|
||||
MF_Rep: ["정비비율", "Maintenance ratio"],
|
||||
MF_Mgmt: ["연간관리비율", "Annual mgmt ratio"],
|
||||
MF_DepCoef: ["상각비계수", "Depreciation coef."],
|
||||
MF_RepCoef: ["정비비계수", "Maintenance coef."],
|
||||
MF_MgmtCoef: ["관리비계수", "Mgmt coef."],
|
||||
MF_Fuel: ["주연료", "Main fuel"],
|
||||
MF_FuelKind: ["연료 종류", "Fuel"],
|
||||
MF_Misc: ["잡품", "Sundries"],
|
||||
MF_Crew: ["조종원", "Operator"],
|
||||
MF_Job: ["직종", "Trade"],
|
||||
MF_Power: ["전력", "Power"],
|
||||
MF_None: ["없음", "None"],
|
||||
MF_Build: ["구성", "Components"],
|
||||
MF_HourlyDirect: ["시간당 값 직접", "Hourly values"],
|
||||
MF_Labor: ["노무비", "Labor"],
|
||||
MF_Material: ["재료비", "Material"],
|
||||
MF_Expense: ["경비", "Expense"],
|
||||
MF_Total: ["계", "Total"],
|
||||
MF_Loss: ["손료", "Ownership"],
|
||||
MF_NumberOnly: ["숫자만", "Numbers only"],
|
||||
MF_Range: ["보통 범위 {value} 밖", "Outside usual range {value}"],
|
||||
MF_Preview: ["시간당 경비 미리보기", "Hourly cost preview"],
|
||||
MF_PreviewNote: [
|
||||
"전국평균 단가 · 넣는 즉시 셈 · 저장 때 서버가 다시 셈",
|
||||
"National average · instant · server recalculates on save",
|
||||
],
|
||||
MF_NoPrice: ["단가 없음", "no price"],
|
||||
MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"],
|
||||
MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"],
|
||||
MF_SimilarNone: ["찾은 기계 없음", "No machines found"],
|
||||
MF_SimilarCopied: ["{value} 값을 복사함", "Copied values from {value}"],
|
||||
MF_Changed: ["고친 칸", "edited"],
|
||||
M01_MachineNew: ["새 기계", "New machine"],
|
||||
M01_MachineBack: ["목록으로", "Back to list"],
|
||||
M01_MachineHourly: ["시간당 경비", "Hourly cost"],
|
||||
M01_MachineMeta: ["원문 · 구분", "Source · group"],
|
||||
M01_MachineUses: ["쓰는 곳", "Used by"],
|
||||
M01_MachineNoUses: ["쓰는 곳 없음", "Not used"],
|
||||
M01_MachineSame: [
|
||||
"같은 이름 · 규격 기계가 있음 — {value}",
|
||||
"A machine with the same name and spec exists — {value}",
|
||||
],
|
||||
M01_MachineDelete: ["지우기", "Delete"],
|
||||
M01_MachineDeleteAsk: [
|
||||
"이 기계를 지울까? 쓰는 곳 {value} — [저장] 해야 지워짐",
|
||||
"Delete this machine? Used by {value} — applied on [Save]",
|
||||
],
|
||||
M01_MachineLeave: [
|
||||
"저장 안 한 고침이 있음. 나갈까? (고침은 초안에 남음)",
|
||||
"Unsaved edits. Leave? (edits stay in the draft)",
|
||||
],
|
||||
M01_MachineNotFound: ["그 기계가 없음", "Machine not found"],
|
||||
M01_MachineReadOnly: ["읽기만 — 시스템 관리자만 고침", "Read only — system admins edit"],
|
||||
M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"],
|
||||
M01_MachineDetailGroup: ["상세구분", "Sub-group"],
|
||||
M01_MachineSourceNo: ["원문번호", "Source no."],
|
||||
} as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */
|
||||
.ui-machine-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ui-machine-form__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-machine-form__coefs .ui-input {
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-machine-form .is-changed .ui-input,
|
||||
.ui-machine-form .is-changed .ui-select {
|
||||
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-machine-form__warn {
|
||||
min-height: 0;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-machine-form__warn:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-machine-form__similar {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.ui-machine-form__similar .ui-find__list {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.ui-machine-form__preview .ui-calc-row--formula {
|
||||
grid-template-columns: 64px minmax(0, 1fr) 96px;
|
||||
}
|
||||
|
||||
.ui-machine-form__rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--spacing-4) 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ui-machine-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ui-machine-form__preview {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
/* =============================================================================
|
||||
* ui_template_machine_form.ts
|
||||
* 부품 「기계 양식」(PLAN 821-2 · 요구서 796 1 장) — M01 기계 상세 · M02 내 기계 · 불러오기가 같이 씀.
|
||||
*
|
||||
* 칸 = 이름 · 규격 / 가격(천원) / 손료계수(바로 넣기 · 품셈 방식) / 주연료(종류 고르기 + ℓ/hr) /
|
||||
* 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택) · 「시간당 값 직접」 꼴(allowHourly)
|
||||
* 옆 = 시간당 경비 계산식 즉시(`common_util_machine_coef` · 정본은 서버 식)
|
||||
* 찾기 = 「비슷한 기계에서 시작」(findSimilar 를 주면) — 고르면 값 · 이름 · 규격 복사
|
||||
* 상태 = 숫자만(아니면 오류) · 범위 밖 경고 · 고친 칸 노랑 · 같은 이름 알림 자리(setNotice) · readOnly
|
||||
* 자료 = 목록(연료 · 직종 · 단가) · 찾기 · 저장은 부르는 쪽이 줌 — 이 부품은 M01 · M02 에 안 묶임
|
||||
* 값 묶음 = 기계 줄 `값` 꼴 그대로(칸 이름 같음) · 안 보이는 칸(선원 등)은 손대지 않음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
MACHINE_COEFS,
|
||||
MACHINE_INPUTS,
|
||||
machineCoefOf,
|
||||
machineHourly,
|
||||
} from "../common_util/common_util_machine_coef";
|
||||
import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row";
|
||||
import { el, createInputField, createSelectField } from "./ui_template_elements";
|
||||
import { createFindList } from "./ui_template_find_list";
|
||||
import { t as L } from "./ui_template_locale";
|
||||
import { createSegment } from "./ui_template_segment";
|
||||
import { createText } from "./ui_template_text";
|
||||
import "./ui_template_machine_form.css";
|
||||
|
||||
export type MachineValues = Record<string, unknown>;
|
||||
|
||||
/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */
|
||||
export interface MachinePick {
|
||||
value: string;
|
||||
text: string;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export interface MachineSimilar {
|
||||
key: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
/** 흐린 꼬리표(구분 · 내 기계 등) */
|
||||
tag?: string;
|
||||
values: MachineValues;
|
||||
}
|
||||
|
||||
export interface MachineFormOptions {
|
||||
name?: string;
|
||||
spec?: string;
|
||||
values?: MachineValues;
|
||||
/** 손료계수 방식 기본 — 값에 품셈 입력 다섯이 있으면 늘 품셈 방식 */
|
||||
coefMode?: "direct" | "pumsem";
|
||||
/** 「시간당 값 직접」 꼴 허용(내 기계) */
|
||||
allowHourly?: boolean;
|
||||
fuels: MachinePick[];
|
||||
jobs: MachinePick[];
|
||||
/** 전력 단가(원/kWh) · 시간환산(조종원 하루 → 한 시간) */
|
||||
power?: number;
|
||||
hour?: number;
|
||||
readOnly?: boolean;
|
||||
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
|
||||
onSimilar?: (item: MachineSimilar) => void;
|
||||
onChange?: () => void;
|
||||
}
|
||||
|
||||
export interface MachineFormState {
|
||||
name: string;
|
||||
spec: string;
|
||||
values: MachineValues;
|
||||
/** 숫자 아닌 칸 등 — 있으면 저장 막음 */
|
||||
errors: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface MachineFormHandle {
|
||||
root: HTMLElement;
|
||||
state: () => MachineFormState;
|
||||
/** 같은 이름 · 규격 알림 등(빈 글 = 지움) */
|
||||
setNotice: (text: string) => void;
|
||||
}
|
||||
|
||||
/** 시간당 값 직접 칸 · 비목 */
|
||||
export const HOURLY_KEY = "시간당";
|
||||
const HOURLY_COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const HOURLY_LABEL = { 노무비: "MF_Labor", 재료비: "MF_Material", 경비: "MF_Expense" } as const;
|
||||
|
||||
interface NumSpec {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
const n = (key: string, label: string, unit: string, min: number, max: number): NumSpec => ({
|
||||
key,
|
||||
label,
|
||||
unit,
|
||||
min,
|
||||
max,
|
||||
});
|
||||
const PRICE = n("가격", "MF_Price", "천원", 0, 5_000_000);
|
||||
const COEF = n("손료계수", "MF_Coef", "10⁻⁷/hr", 1, 100_000);
|
||||
const PUMSEM = [
|
||||
n("내용시간", "MF_Life", "시간", 100, 100_000),
|
||||
n("연간표준가동시간", "MF_Yearly", "시간", 100, 8_760),
|
||||
n("상각비율", "MF_Dep", "", 0, 1),
|
||||
n("정비비율", "MF_Rep", "", 0, 3),
|
||||
n("연간관리비율", "MF_Mgmt", "", 0, 1),
|
||||
];
|
||||
const FUEL = n("주연료", "MF_Fuel", "ℓ/hr", 0, 2_000);
|
||||
const MISC = n("잡재료율", "MF_Misc", "%", 0, 100);
|
||||
const CREW = n("조종원", "MF_Crew", "인", 0, 20);
|
||||
const POWER = n("전력", "MF_Power", "kWh", 0, 10_000);
|
||||
const COEF_LABEL: Record<string, string> = {
|
||||
상각비계수: "MF_DepCoef",
|
||||
정비비계수: "MF_RepCoef",
|
||||
관리비계수: "MF_MgmtCoef",
|
||||
손료계수: "MF_Coef",
|
||||
};
|
||||
|
||||
const tr = (key: string): string => L(key as "MF_Name");
|
||||
const fmt = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||||
const shown = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
/** 글 → 수 · 빈 글 = null · 수 아님 = NaN */
|
||||
function parseNum(text: string): number | null {
|
||||
const s = text.replace(/,/g, "").trim();
|
||||
if (!s) return null;
|
||||
return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN;
|
||||
}
|
||||
|
||||
export function createMachineForm(opts: MachineFormOptions): MachineFormHandle {
|
||||
const init: MachineValues = structuredClone(opts.values ?? {});
|
||||
let values: MachineValues = structuredClone(init);
|
||||
let name = opts.name ?? "";
|
||||
let spec = opts.spec ?? "";
|
||||
const startName = name;
|
||||
const startSpec = spec;
|
||||
const hasPumsem = MACHINE_INPUTS.some((k) => typeof init[k] === "number");
|
||||
let mode: "direct" | "pumsem" = hasPumsem ? "pumsem" : (opts.coefMode ?? "direct");
|
||||
let hourlyMode = init[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
|
||||
const errors = new Map<string, string>();
|
||||
|
||||
const root = el("div", { className: "ui-machine-form" });
|
||||
const fields = el("div", { className: "ui-machine-form__fields" });
|
||||
const side = el("aside", { className: "ui-machine-form__preview" });
|
||||
const notice = el("div", { className: "ui-machine-form__notice" });
|
||||
|
||||
/** 칸 값을 값 묶음에 — 빈 값은 원래 칸이 있었으면 null · 없었으면 칸을 뺌 */
|
||||
const put = (key: string, value: unknown): void => {
|
||||
if (value === null || value === "") {
|
||||
if (key in init) values[key] = null;
|
||||
else delete values[key];
|
||||
} else values[key] = value;
|
||||
};
|
||||
|
||||
const changed = (): boolean => name !== startName || spec !== startSpec || !same(values, init);
|
||||
|
||||
/** 품셈 입력이 처음과 같으면 저장된 계수 그대로(원문 표 값) · 다르면 계수 짝으로 셈 */
|
||||
const syncCoefs = (): void => {
|
||||
if (mode !== "pumsem") return;
|
||||
const sameInputs = MACHINE_INPUTS.every((k) => same(values[k], init[k]));
|
||||
const got = sameInputs ? null : machineCoefOf(values);
|
||||
for (const k of MACHINE_COEFS) {
|
||||
if (sameInputs) put(k, init[k] ?? null);
|
||||
else put(k, got ? got[k] : null);
|
||||
}
|
||||
};
|
||||
|
||||
const fire = (): void => {
|
||||
syncCoefs();
|
||||
paintPreview();
|
||||
paintCoefs();
|
||||
opts.onChange?.();
|
||||
};
|
||||
|
||||
/** 숫자 칸 — 숫자만 · 범위 밖 경고 · 고친 칸 노랑 */
|
||||
const numField = (spec_: NumSpec, read = false, hold?: MachineValues): HTMLElement => {
|
||||
const bag = hold ?? values;
|
||||
const field = createInputField({
|
||||
label: tr(spec_.label),
|
||||
value: shown(bag[spec_.key]),
|
||||
unit: spec_.unit || undefined,
|
||||
size: 28,
|
||||
});
|
||||
field.input.inputMode = "decimal";
|
||||
field.input.dataset.key = spec_.key;
|
||||
const warn = el("span", { className: "ui-machine-form__warn" });
|
||||
field.root.append(warn);
|
||||
const mark = (): void => {
|
||||
const now = hold ? hold[spec_.key] : values[spec_.key];
|
||||
const was = hold
|
||||
? (init[HOURLY_KEY] as MachineValues | undefined)?.[spec_.key]
|
||||
: init[spec_.key];
|
||||
field.root.classList.toggle("is-changed", !same(now ?? null, was ?? null));
|
||||
};
|
||||
if (read || opts.readOnly) field.input.readOnly = true;
|
||||
field.input.addEventListener("input", () => {
|
||||
const v = parseNum(field.input.value);
|
||||
warn.textContent = "";
|
||||
if (Number.isNaN(v)) {
|
||||
errors.set(spec_.key, `${tr(spec_.label)} · ${L("MF_NumberOnly")}`);
|
||||
field.setError(L("MF_NumberOnly"));
|
||||
} else {
|
||||
errors.delete(spec_.key);
|
||||
field.setError();
|
||||
if (v !== null && (v < spec_.min || v > spec_.max))
|
||||
warn.textContent = L("MF_Range").replace(
|
||||
"{value}",
|
||||
`${fmt(spec_.min)}~${fmt(spec_.max)}`,
|
||||
);
|
||||
if (hold) {
|
||||
if (v === null) delete hold[spec_.key];
|
||||
else hold[spec_.key] = v;
|
||||
values[HOURLY_KEY] = { ...hold };
|
||||
} else put(spec_.key, v);
|
||||
}
|
||||
mark();
|
||||
fire();
|
||||
});
|
||||
mark();
|
||||
return field.root;
|
||||
};
|
||||
|
||||
/** 고르기 칸 — 없음 + 목록 · 목록에 없는 지금 값도 한 칸(단가 없음) */
|
||||
const pickField = (key: string, label: string, list: MachinePick[]): HTMLElement => {
|
||||
const now = shown(values[key]);
|
||||
const options = [
|
||||
{ value: "", text: L("MF_None") },
|
||||
...list.map((p) => ({ value: p.value, text: p.text })),
|
||||
];
|
||||
if (now && !list.some((p) => p.value === now))
|
||||
options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` });
|
||||
const field = createSelectField({ label: tr(label), options, value: now, size: 28 });
|
||||
field.select.dataset.key = key;
|
||||
field.select.disabled = Boolean(opts.readOnly);
|
||||
const mark = (): void => {
|
||||
field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null));
|
||||
};
|
||||
field.select.addEventListener("change", () => {
|
||||
put(key, field.select.value || null);
|
||||
mark();
|
||||
fire();
|
||||
});
|
||||
mark();
|
||||
return field.root;
|
||||
};
|
||||
|
||||
const textField = (
|
||||
label: string,
|
||||
get: () => string,
|
||||
set: (v: string) => void,
|
||||
was: string,
|
||||
): HTMLElement => {
|
||||
const field = createInputField({ label: tr(label), value: get(), size: 28 });
|
||||
field.input.readOnly = Boolean(opts.readOnly);
|
||||
field.input.addEventListener("input", () => {
|
||||
set(field.input.value);
|
||||
field.root.classList.toggle("is-changed", field.input.value !== was);
|
||||
opts.onChange?.();
|
||||
});
|
||||
return field.root;
|
||||
};
|
||||
|
||||
/* --- 손료계수 꼴 --- */
|
||||
const coefBox = el("div", { className: "ui-machine-form__group" });
|
||||
const coefOut = el("div", { className: "ui-machine-form__coefs" });
|
||||
const paintCoefs = (): void => {
|
||||
if (mode !== "pumsem") return;
|
||||
coefOut.replaceChildren(
|
||||
...MACHINE_COEFS.map((k) => {
|
||||
const f = createInputField({ label: tr(COEF_LABEL[k]), value: shown(values[k]), size: 28 });
|
||||
f.input.readOnly = true;
|
||||
f.input.tabIndex = -1;
|
||||
f.root.classList.toggle("is-changed", !same(values[k] ?? null, init[k] ?? null));
|
||||
return f.root;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const paintCoefBox = (): void => {
|
||||
const pumsem = mode === "pumsem";
|
||||
coefBox.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: pumsem ? PUMSEM.map((s) => numField(s)) : [numField(COEF)],
|
||||
}),
|
||||
...(pumsem ? [coefOut] : []),
|
||||
);
|
||||
paintCoefs();
|
||||
};
|
||||
const coefSeg = createSegment({
|
||||
options: [
|
||||
{ value: "direct", label: L("MF_CoefDirect") },
|
||||
{ value: "pumsem", label: L("MF_CoefPumsem") },
|
||||
],
|
||||
value: mode,
|
||||
size: 24,
|
||||
ariaLabel: L("MF_Coef"),
|
||||
onChange: (v) => {
|
||||
mode = v as "direct" | "pumsem";
|
||||
if (mode === "direct") {
|
||||
for (const k of [...MACHINE_INPUTS, ...MACHINE_COEFS.slice(0, 3)]) delete values[k];
|
||||
if (values["손료계수"] === undefined && init["손료계수"] !== undefined)
|
||||
values["손료계수"] = init["손료계수"];
|
||||
} else for (const k of MACHINE_INPUTS) if (init[k] !== undefined) values[k] = init[k];
|
||||
paintCoefBox();
|
||||
fire();
|
||||
},
|
||||
});
|
||||
if (opts.readOnly) coefSeg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
||||
|
||||
/* --- 구성 / 시간당 값 직접 --- */
|
||||
const body = el("div", { className: "ui-machine-form__body" });
|
||||
const paintBody = (): void => {
|
||||
if (hourlyMode) {
|
||||
const hold = { ...((values[HOURLY_KEY] as MachineValues | undefined) ?? {}) };
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: HOURLY_COSTS.map((k) =>
|
||||
numField(n(k, HOURLY_LABEL[k], "원/hr", 0, 10_000_000), false, hold),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
paintCoefBox();
|
||||
body.replaceChildren(
|
||||
numField(PRICE),
|
||||
el("div", {
|
||||
className: "ui-machine-form__group",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-machine-form__head",
|
||||
children: [el("strong", { text: L("MF_Coef") }), coefSeg.root],
|
||||
}),
|
||||
coefBox,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: [
|
||||
pickField("주연료종류", "MF_FuelKind", opts.fuels),
|
||||
numField(FUEL),
|
||||
numField(MISC),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-machine-form__row",
|
||||
children: [pickField("조종원직종", "MF_Job", opts.jobs), numField(CREW), numField(POWER)],
|
||||
}),
|
||||
);
|
||||
};
|
||||
const kindSeg = opts.allowHourly
|
||||
? createSegment({
|
||||
options: [
|
||||
{ value: "build", label: L("MF_Build") },
|
||||
{ value: "hourly", label: L("MF_HourlyDirect") },
|
||||
],
|
||||
value: hourlyMode ? "hourly" : "build",
|
||||
size: 24,
|
||||
onChange: (v) => {
|
||||
hourlyMode = v === "hourly";
|
||||
if (hourlyMode) values[HOURLY_KEY] ??= {};
|
||||
else if (init[HOURLY_KEY] === undefined) delete values[HOURLY_KEY];
|
||||
paintBody();
|
||||
fire();
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
/* --- 비슷한 기계에서 시작 --- */
|
||||
const similarBox = (): HTMLElement[] => {
|
||||
if (!opts.findSimilar || opts.readOnly) return [];
|
||||
const find = createFindList({ placeholder: L("MF_SimilarFind") });
|
||||
const wrap = el("details", {
|
||||
className: "ui-machine-form__similar",
|
||||
children: [el("summary", { text: L("MF_Similar") }), find.root],
|
||||
});
|
||||
let seq = 0;
|
||||
let timer: number | undefined;
|
||||
const look = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const got = await opts.findSimilar!(find.search.value.trim()).catch(() => []);
|
||||
if (mine !== seq) return;
|
||||
find.hint.textContent = got.length ? "" : L("MF_SimilarNone");
|
||||
find.setRows(
|
||||
got.map((m) => ({
|
||||
key: m.key,
|
||||
name: `${m.name} ${m.spec}`.trim(),
|
||||
tag: m.tag,
|
||||
sub: m.key,
|
||||
onPick: () => {
|
||||
values = { ...structuredClone(m.values) };
|
||||
name = m.name;
|
||||
spec = m.spec;
|
||||
mode = MACHINE_INPUTS.some((k) => typeof values[k] === "number") ? "pumsem" : mode;
|
||||
coefSeg.set(mode);
|
||||
hourlyMode = values[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly);
|
||||
kindSeg?.set(hourlyMode ? "hourly" : "build");
|
||||
wrap.open = false;
|
||||
paintAll();
|
||||
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
|
||||
opts.onSimilar?.(m);
|
||||
fire();
|
||||
},
|
||||
})),
|
||||
);
|
||||
};
|
||||
find.search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void look(), 250);
|
||||
});
|
||||
wrap.addEventListener("toggle", () => wrap.open && void look());
|
||||
return [wrap];
|
||||
};
|
||||
|
||||
/* --- 미리보기 --- */
|
||||
const paintPreview = (): void => {
|
||||
const out = (v: number, key = false): HTMLElement =>
|
||||
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: fmt(v) });
|
||||
const line = (label: string, text: string, v: number, key = false): HTMLElement =>
|
||||
createCalcFormulaRow(label, text ? createCalcFormula(text) : null, out(v, key));
|
||||
const head = [
|
||||
el("strong", { text: L("MF_Preview") }),
|
||||
createText(L("MF_PreviewNote"), "muted"),
|
||||
];
|
||||
if (hourlyMode) {
|
||||
const h = (values[HOURLY_KEY] as Record<string, number> | undefined) ?? {};
|
||||
const [a, b, c] = HOURLY_COSTS.map((k) => Number(h[k]) || 0);
|
||||
side.replaceChildren(
|
||||
...head,
|
||||
line(L("MF_Labor"), "", a),
|
||||
line(L("MF_Material"), "", b),
|
||||
line(L("MF_Expense"), "", c),
|
||||
line(L("MF_Total"), "", a + b + c, true),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const fuel = opts.fuels.find((f) => f.value === values["주연료종류"])?.price ?? 0;
|
||||
const wage = opts.jobs.find((j) => j.value === String(values["조종원직종"] ?? ""))?.price ?? 0;
|
||||
const hour = opts.hour ?? 1 / 8;
|
||||
const p = machineHourly(values, fuel, wage, opts.power ?? 0, hour);
|
||||
const num = (k: string): string => fmt(Number(values[k]) || 0);
|
||||
const rows = [
|
||||
line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료),
|
||||
line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료),
|
||||
line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품),
|
||||
line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원),
|
||||
...(values["전력"]
|
||||
? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)]
|
||||
: []),
|
||||
];
|
||||
side.replaceChildren(
|
||||
...head,
|
||||
...rows,
|
||||
el("hr", { className: "ui-machine-form__rule" }),
|
||||
line(L("MF_Labor"), "", p.노무비),
|
||||
line(L("MF_Material"), "", p.재료비),
|
||||
line(L("MF_Expense"), "", p.경비),
|
||||
line(L("MF_Total"), "", p.계, true),
|
||||
);
|
||||
};
|
||||
|
||||
const setNotice = (text: string): void => {
|
||||
notice.replaceChildren(...(text ? [createText(text, "warning")] : []));
|
||||
};
|
||||
|
||||
const top = el("div", { className: "ui-machine-form__row" });
|
||||
const paintAll = (): void => {
|
||||
errors.clear();
|
||||
syncCoefs();
|
||||
top.replaceChildren(
|
||||
textField(
|
||||
"MF_Name",
|
||||
() => name,
|
||||
(v) => (name = v),
|
||||
startName,
|
||||
),
|
||||
textField(
|
||||
"MF_Spec",
|
||||
() => spec,
|
||||
(v) => (spec = v),
|
||||
startSpec,
|
||||
),
|
||||
);
|
||||
paintBody();
|
||||
paintPreview();
|
||||
};
|
||||
fields.append(...similarBox(), notice, top, ...(kindSeg ? [kindSeg.root] : []), body);
|
||||
root.append(fields, side);
|
||||
root.classList.toggle("is-readonly", Boolean(opts.readOnly));
|
||||
paintAll();
|
||||
|
||||
return {
|
||||
root,
|
||||
state: () => ({
|
||||
name: name.trim(),
|
||||
spec: spec.trim(),
|
||||
values: structuredClone(values),
|
||||
errors: [...errors.values()],
|
||||
changed: changed(),
|
||||
}),
|
||||
setNotice,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { createRangeBar } from "./ui_template_range_bar";
|
||||
import { createStat, statGrid } from "./ui_template_stat";
|
||||
import { toastNode } from "./ui_template_toast";
|
||||
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
|
||||
import { createMachineForm } from "./ui_template_machine_form";
|
||||
|
||||
/** 상태 줄 다섯 칸 */
|
||||
export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const;
|
||||
@@ -321,6 +322,22 @@ function tabsDemo(s: KitState): HTMLElement | null {
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */
|
||||
const kitMachine = (readOnly: boolean): HTMLElement =>
|
||||
createMachineForm({
|
||||
name: "굴착기(무한궤도)",
|
||||
spec: "1.0㎥",
|
||||
values: {
|
||||
...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 },
|
||||
...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 },
|
||||
...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" },
|
||||
},
|
||||
fuels: [{ value: "경유", text: "경유", price: 1846.39 }],
|
||||
jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }],
|
||||
readOnly,
|
||||
findSimilar: async () => [],
|
||||
}).root;
|
||||
|
||||
// prettier-ignore
|
||||
export const KIT_PARTS: KitPart[] = [
|
||||
// ── 552-2 기본 ─────────────────────────────────────────────
|
||||
@@ -386,6 +403,8 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts",
|
||||
demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) },
|
||||
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
|
||||
demo: (s) => (s === "default" ? kitEquation() : null) },
|
||||
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
|
||||
|
||||
Reference in New Issue
Block a user