Files
Aislo/tools/stmate-test/scr_ui_text.js
T
eomsangdonandClaude Opus 5.5 086750b6d4 feat(stmate-test): 2차 화면·코드관리 합침 · % 줄(조달수수료) 계산·저장 재계산 · 줄 고정 단가 보존 · 장부
- 코드 등록·삭제·변경·병합·데이타병합·일괄변경·검색·동일코드·풀어넣기 끝전 등(ui_code · ui_merge · api_ui_code)
- 코드 붙은 % 줄 = 같은 상위 공종 앞 말단 줄 비목 합 × 율(server pct_row_base · calc.js) · 저장 때 다시 셈(stc_save _pct_rows) — 실무 7건 저장값 그대로
- 내역 줄 단가 ≠ 단가목록이면 줄 고정 단가(fixedUnit)
- 메뉴 94개 눌러 오류 0 · 시험 502 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:10:16 +09:00

325 lines
16 KiB
JavaScript

/**
* STMATE w 8.0 — 글자 입력 도우미·내역 보조 창 끼워 넣기 (scr_ui_text.js)
* 특수문자(mMinMsg_ · 14종 · 문자 입력후 종료 · 다중문자 동시입력) · 사용자문자 편집(wM_Edit_USER · 검색명칭/입력내용/적용)
* · 메모 입력(wInDlgMemo · Ok/Cancel/Load/Clear — 내역 행 DES1·DES2) · 동일코드 추려보기(wSameCode «추려보기» · N번 사용중 · 더블클릭 → 내역 위치)
* · 실적단가 → 일식단가 변환등록(wM_Conv_A2W · 항목선택 → 비목율 → 변환코드 → 변환/생성)
* 글자 넣는 자리 = 마지막으로 손댄 입력칸·글상자(커서 자리) · 사용자문자 저장 = 브라우저(localStorage, 원본 USR_SYNC.DBF 자리)
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const UA = global.STMATE_UI_CODE_AMT;
const UC = global.STMATE_UI_CODE;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
// 마지막 입력 자리 — 창(모달) 안 입력칸은 빼고 작업 화면 칸만 기억
let target = null;
document.addEventListener("focusin", (e) => {
const el = e.target;
if (
/^(INPUT|TEXTAREA)$/.test(el.tagName) &&
!el.closest("#stmate-modal-window") &&
el.type !== "checkbox"
)
target = el;
});
function insertText(s) {
const el = target;
if (!el || !document.body.contains(el))
return (navigator.clipboard && navigator.clipboard.writeText(s), false);
const a = el.selectionStart ?? el.value.length,
b = el.selectionEnd ?? el.value.length;
el.value = el.value.slice(0, a) + s + el.value.slice(b);
el.selectionStart = el.selectionEnd = a + s.length;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
const where = () =>
target && document.body.contains(target)
? `넣을 자리: ${target.id || target.className || target.tagName}`
: "넣을 자리 없음 — 클립보드로 복사";
// ── 특수문자 ──
const dec = typeof TextDecoder !== "undefined" ? new TextDecoder("euc-kr") : null;
function openSpChar(ctx, api) {
const s = (ctx.uiState.spc = ctx.uiState.spc || { i: 0, close: true, multi: false, buf: "" });
const chars = dec ? UA.specialChars(s.i, (b) => dec.decode(b)) : [];
api.openModal(
"특수문자 [mMinMsg_]",
`<div class="stm-form" style="display:flex;gap:10px;"><select id="sc-kind" size="14" class="win-select">${UA.SPECIAL.map(([t], i) => `<option value="${i}" ${s.i === i ? "selected" : ""}>${t}</option>`).join("")}</select>
<div><div style="display:grid;grid-template-columns:repeat(16,26px);gap:2px;max-height:50vh;overflow:auto;">${chars.map((c) => `<button class="win-btn sc-ch" style="padding:2px;">${esc(c)}</button>`).join("")}</div>
<div class="stm-row"><label>입력될문자</label><input id="sc-buf" class="win-input" value="${esc(s.buf)}" style="width:160px;" /><label><input type="checkbox" id="sc-close" ${s.close ? "checked" : ""} /> 문자 입력후 종료</label><label><input type="checkbox" id="sc-multi" ${s.multi ? "checked" : ""} /> 다중문자 동시입력</label><button class="win-btn primary" id="sc-put">입력</button></div>
<p class="stm-note">${esc(where())}</p></div></div>`,
);
$("sc-kind").onchange = (e) => ((s.i = Number(e.target.value)), openSpChar(ctx, api));
$("sc-close").onchange = (e) => (s.close = e.target.checked);
$("sc-multi").onchange = (e) => (s.multi = e.target.checked);
const put = (t) => {
insertText(t);
s.buf = "";
if (s.close) api.closeModal();
};
document
.querySelectorAll(".sc-ch")
.forEach(
(b) =>
(b.onclick = () =>
s.multi
? (($("sc-buf").value += b.textContent), (s.buf = $("sc-buf").value))
: put(b.textContent)),
);
$("sc-put").onclick = () => put($("sc-buf").value);
}
// ── 사용자문자 편집 ──
const UKEY = "stmate_user_text";
const loadU = () => {
try {
return JSON.parse(global.localStorage.getItem(UKEY) || "[]");
} catch (_e) {
return [];
}
};
const saveU = (list) => {
try {
global.localStorage.setItem(UKEY, JSON.stringify(list));
} catch (_e) {
// 저장 공간 없음 — 이번 실행 동안만
}
};
function openUser(ctx, api) {
const list = loadU();
const u = (ctx.uiState.usr = ctx.uiState.usr || { kw: "", sel: 0 });
const shown = UA.userTextFind(
list.map((x, i) => Object.assign({ i }, x)),
u.kw,
).map((x) => x.i);
const all = u.kw ? shown : list.map((_x, i) => i);
api.openModal(
"사용자문자 편집 [wM_Edit_USER]",
`<div class="stm-form"><div class="stm-row"><input id="us-kw" class="win-input" value="${esc(u.kw)}" placeholder="검색 명칭" style="width:160px;" />
<button class="win-btn" id="us-add">항목추가</button><button class="win-btn" id="us-del">항목삭제</button><button class="win-btn" id="us-up">위로 이동</button><button class="win-btn" id="us-dn">아래로이동</button><button class="win-btn" id="us-init">초기화</button><button class="win-btn" id="us-sp">특수문자 불러오기</button></div>
<div class="grid-scroll" style="max-height:46vh;"><table class="dbgrid"><thead><tr><th></th><th>검 색 명 칭</th><th>입 력 내 용</th><th>적용</th><th></th></tr></thead><tbody>${all
.map(
(i) =>
`<tr class="${u.sel === i ? "boq-sel" : ""}"><td class="center"><input type="radio" name="us-r" class="us-r" data-i="${i}" ${u.sel === i ? "checked" : ""} /></td><td><input class="cell-input us-k" data-i="${i}" value="${esc(list[i].key)}" /></td><td><input class="cell-input us-t" data-i="${i}" value="${esc(list[i].text)}" style="width:320px;" /></td><td class="center"><input type="checkbox" class="us-on" data-i="${i}" ${list[i].on !== false ? "checked" : ""} /></td><td><button class="win-btn us-put" data-i="${i}">넣기</button></td></tr>`,
)
.join("")}</tbody></table></div><p class="stm-note">${esc(where())}</p></div>`,
);
const re = () => openUser(ctx, api);
const save = () => (saveU(list), re());
$("us-kw").onchange = (e) => ((u.kw = e.target.value), re());
document
.querySelectorAll(".us-r")
.forEach((r) => (r.onchange = () => (u.sel = Number(r.getAttribute("data-i")))));
document
.querySelectorAll(".us-k")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].key = r.value),
saveU(list)
)),
);
document
.querySelectorAll(".us-t")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].text = r.value),
saveU(list)
)),
);
document
.querySelectorAll(".us-on")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].on = r.checked),
saveU(list)
)),
);
document
.querySelectorAll(".us-put")
.forEach(
(b) =>
(b.onclick = () => (
insertText(list[Number(b.getAttribute("data-i"))].text || ""),
api.closeModal()
)),
);
$("us-add").onclick = () => (
list.splice(u.sel + 1, 0, { key: "", text: "", on: true }),
(u.sel += 1),
save()
);
$("us-del").onclick = () => list[u.sel] && (list.splice(u.sel, 1), save());
const mv = (d) => {
const j = u.sel + d;
if (!list[u.sel] || j < 0 || j >= list.length) return;
[list[u.sel], list[j]] = [list[j], list[u.sel]];
u.sel = j;
save();
};
$("us-up").onclick = () => mv(-1);
$("us-dn").onclick = () => mv(1);
$("us-init").onclick = () => confirm("사용자문자 목록을 비움") && (list.splice(0), save());
$("us-sp").onclick = () => openSpChar(ctx, api);
}
// ── 내역 메모(DES1·DES2) ──
const SC = () => global.STMATE_UI_BOQ_SCREEN;
const selRow = (ctx) =>
(ctx.dbfData.boq_rows || []).find((r) => Number(r._recno) === (ctx.uiState.boqSel || [])[0]);
function openMemo(ctx, api) {
const r = selRow(ctx);
if (!r) return alert("메모를 넣을 내역 행을 고르세요");
api.openModal(
`메모 입력 [wInDlgMemo] — ${esc(r.ITNUM || "")} ${esc(r.NAME1 || r.BDCODE1 || "")}`,
`<div class="stm-form"><textarea id="mm-t" class="win-input" style="width:520px;height:120px;">${esc([r.DES1, r.DES2].filter((x) => String(x || "").trim()).join("\n"))}</textarea>
<div class="stm-row"><button class="win-btn primary" id="mm-ok">Ok</button><button class="win-btn" id="mm-no">Cancel</button><input type="file" id="mm-file" accept=".txt" style="display:none;" /><button class="win-btn" id="mm-load">Load</button><button class="win-btn" id="mm-clr">Clear</button></div>
<p class="stm-note">줄 1 = 비고1(DES1) · 줄 2 = 비고2(DES2) — 내역 저장 때 함께 씀</p></div>`,
);
$("mm-no").onclick = api.closeModal;
$("mm-clr").onclick = () => ($("mm-t").value = "");
$("mm-load").onclick = () => $("mm-file").click();
$("mm-file").onchange = (e) => {
const f = e.target.files[0];
if (f) f.text().then((t) => ($("mm-t").value = t));
};
$("mm-ok").onclick = () => {
const [a, b] = $("mm-t").value.split(/\r?\n/);
Object.assign(r, { DES1: (a || "").trim(), DES2: (b || "").trim() });
api.closeModal();
SC().after(ctx, api, { ok: true }, true);
};
}
// ── 동일코드 추려보기 ──
function openSame(ctx, api) {
const r = selRow(ctx);
const c = r ? String(r.BDCODE1 || "").trim() : "";
if (!c || /^(T\d|E\d|C\d|:)$/.test(c)) return alert("단가코드 행을 고르세요");
const ev = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx).filter(
(x) => String(x.BDCODE1 || "").trim() === c,
);
api.openModal(
`추려보기 [wSameCode] — ${c}`,
`<div class="stm-form"><p><strong>${ev.length}번 사용중</strong> · Enter/더블클릭 → 내역서의 해당위치로 이동</p><div class="grid-scroll" style="max-height:50vh;"><table class="dbgrid"><thead><tr><th>공종번호</th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th><th>단가</th><th>금액</th><th>노무비</th><th>재료비</th><th>경비</th><th>비고</th></tr></thead><tbody>${ev
.map(
(x) =>
`<tr class="sm-go" data-r="${x._recno}" tabindex="0"><td>${esc(x.ITNUM)}</td><td>${esc(x.NAME1)}</td><td>${esc(x.SIZE1)}</td><td class="center">${esc(x.UNIT1)}</td><td class="num">${esc(x.QTY1)}</td><td class="num">${api.fmt(num(x.TAMT1))}</td><td class="num">${api.fmt(num(x.T_AMT1))}</td><td class="num">${api.fmt(num(x.L_AMT1))}</td><td class="num">${api.fmt(num(x.M_AMT1))}</td><td class="num">${api.fmt(num(x.S_AMT1))}</td><td>${esc(x.BDCODE1)}</td></tr>`,
)
.join("")}</tbody></table></div></div>`,
);
const go = (tr) => {
const rec = Number(tr.getAttribute("data-r"));
Object.assign(ctx.uiState, { boqSel: [rec], boqLevelMin: 0, boqScrollTo: rec });
api.closeModal();
api.renderWorkspace();
};
document
.querySelectorAll(".sm-go")
.forEach(
(tr) => (
(tr.ondblclick = () => go(tr)),
(tr.onkeydown = (e) => e.key === "Enter" && go(tr))
),
);
}
// ── 실적단가 → 일식단가 ──
function openA2W(ctx, api) {
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
const src = ctx.dbfData.costa_rows || [];
const A = global.STMATE_A2W;
const w = (ctx.uiState.a2w = ctx.uiState.a2w || {
sel: {},
L: 100,
M: 0,
own: false,
start: UC.nextCode(ctx.dbfData.costn_rows, "W"),
});
if (!src.length)
return api.openModal(
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
'<p class="stm-warn" style="padding:12px;">이 프로젝트에 실적단가(COSTA.DBF) 자료가 없음</p>',
);
const ed = A ? A.normalizeEditor(w.L, w.M, "L") : { L: w.L, M: w.M, E: 100 - w.L - w.M };
api.openModal(
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
`<div class="stm-form"><div class="grid-scroll" style="max-height:36vh;"><table class="dbgrid"><thead><tr><th>선택</th><th>코드</th><th>공종</th><th>명칭</th><th>규격</th><th>단위</th><th>단가</th><th>노무비율</th></tr></thead><tbody>${src
.map(
(r, i) =>
`<tr><td class="center"><input type="checkbox" class="aw-sel" data-i="${i}" ${w.sel[i] ? "checked" : ""} /></td><td>${esc(r.CODE)}</td><td>${esc(r.CODEA || "")}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td>${esc(r.UNIT)}</td><td class="num">${api.fmt(num(r.AAMT1))}</td><td class="num">${esc(r.RATE_L || "")}</td></tr>`,
)
.join("")}</tbody></table></div>
<div class="stm-row"><label>비 목 율( 단가 => 노,재,경 ) 노무비율</label><input id="aw-l" type="number" class="win-input" value="${ed.L}" style="width:70px;" /><label>재료비율</label><input id="aw-m" type="number" class="win-input" value="${ed.M}" style="width:70px;" /><span>경 비 율 ${ed.E} %</span>
<label><input type="checkbox" id="aw-own" ${w.own ? "checked" : ""} /> 【 노무비율ː개별노무비율 】【 나머지ː재료비우선 】</label></div>
<div class="stm-row"><label>신규 시작코드</label><input id="aw-start" class="win-input" value="${esc(w.start)}" style="width:90px;" /><button class="win-btn primary" id="aw-ok">변환/생성</button></div></div>`,
);
document
.querySelectorAll(".aw-sel")
.forEach((i) => (i.onchange = () => (w.sel[i.getAttribute("data-i")] = i.checked)));
$("aw-ok").onclick = () => {
Object.assign(w, {
L: num($("aw-l").value),
M: num($("aw-m").value),
own: $("aw-own").checked,
start: $("aw-start").value.trim().toUpperCase(),
});
const rows = src.map((r, i) => ({ sel: Boolean(w.sel[i]), rateL: num(r.RATE_L) }));
const rates = A
? A.bulkApply(rows, { L: w.L, M: w.M, sender: "L" }, { individual: w.own })
: rows;
const pick = src.map((r, i) => Object.assign({}, r, rates[i])).filter((_r, i) => w.sel[i]);
if (!pick.length) return alert("변환할 항목을 고르세요");
const taken = new Set(ctx.dbfData.costn_rows.map(UC.codeOf));
const made = UA.a2w(pick, { start: w.start, taken });
const env = { d: ctx.dbfData, state: ctx.state, memo: ctx.uiState.editedSmtMap };
made.forEach((m) =>
UC.addCode(env, {
kind: "W",
code: m.CODE,
fields: {
NAME: m.NAME,
SIZE: m.SIZE,
UNIT: m.UNIT,
MAMT1: String(m.M),
LAMT1: String(m.L),
SAMT1: String(m.S),
TAMT1: String(m.T),
},
}),
);
ctx.uiState.danMsg = `일식단가(W) ${made.length}건 생성 ${made[0].CODE}~`;
w.sel = {};
api.closeModal();
api.cascadeAndRender();
};
}
Object.assign(P.actions, {
Act_UI_SpChar: (ctx, api) => openSpChar(ctx, api),
Act_UI_UserText: (ctx, api) => openUser(ctx, api),
Act_UI_A2W: (ctx, api) => openA2W(ctx, api),
});
P.menus.push(
{ menu: "m4", act: "Act_UI_SpChar", label: "특수문자" },
{ menu: "m4", act: "Act_UI_UserText", label: "사용자문자 편집" },
{ menu: "m2", act: "Act_UI_A2W", label: "실적단가(A) ⇒ 일식단가(W) 변환" },
);
global.STMATE_UI_TEXT = { insertText, openSpChar, openUser, openMemo, openSame, openA2W };
})(window);