feat(b09): 단가연결 모달 — 연결 하위 메뉴 다섯 → 한 창(종류 고르기 · 검색) · 이름 단가연결 (251-3 · 251-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 01:18:31 +09:00
co-authored by Claude Opus 5.5
parent 0e6da95c95
commit 8e7a795abc
10 changed files with 178 additions and 127 deletions
+2 -2
View File
@@ -227,10 +227,10 @@ console.log(JSON.stringify(out));
out = json.loads(_node(src + body))
# 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로
assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"]
assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
assert out["auto"] == ["항목 추가", "-", "단가연결", "-", "아래로 ↓", "내어쓰기 ←",
"-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip
# 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음)
assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑",
assert out["hand"] == ["항목 추가", "-", "단가연결", "단가연결 풀기", "-", "위로 ↑",
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip
# 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
below = out["below"]
+17 -15
View File
@@ -266,6 +266,13 @@ globalThis.openModal = (o) => {
picked.title = o.title;
o.mount({ append() {} }, () => (picked.closed = true));
};
// 251-6 단가연결 = 한 모달(openLogicPicker 바탕) — 호표 줄 = lib.extra
globalThis.loadRows = async () => [];
globalThis.openLogicPicker = (done, lib) => {
picked.title = lib.title;
picked.kinds = lib.kindFilter.map((k) => k.text);
picked.rows = lib.extra;
};
let user = __USER__;
const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {},
get user() { return user; }, setUser: (u) => (user = u) };
@@ -288,11 +295,11 @@ const out = {
table.onCell(at("hand:a"), "connect");
table.onCell(at("hand:g1"), "connect");
table.onCell(at("earth:4"), "connect");
table.onCell(at("hand:c"), "connect");
table.onCell(at("hand:c"), "connect"); // 연결 없음 = 단가연결 창 바로(메뉴 없음)
await new Promise((r) => setTimeout(r, 0));
out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]);
opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창
picked.rows[0].onPick();
out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)];
picked.rows.find((r) => r.tag === "대가3").onPick();
out.picked = [picked.title, picked.kinds, picked.rows.map((r) => r.tag)];
out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref;
console.log(JSON.stringify(out));
"""
@@ -318,17 +325,12 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
] # fmt: skip 201-1 레벨 = 공용 표 level
assert out["lv"] == ["name", 1, 3, 4, 3]
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
picks = [
"일위대가 호표",
"단가산출 호표",
"구조물 조합",
"일위대가 사전조합",
"단가산출",
] # 204-2 · 246-1 이름 셋
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용
# 251-6 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄 = [단가연결 · 단가연결 풀기] ·
# 연결 없는 줄 = 단가연결 창 바로(종류 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘)
unlink = [10, 40, ["단가연결", "-", "단가연결 풀기"]]
assert out["menus"] == [unlink, unlink]
kinds = ["단가산출", "일위대가 사전조합", "구조물 조합", "일위대가 호표", "단가산출 호표"]
assert out["picked"] == ["단가연결", kinds, ["대가3"]]
assert out["ref"] == DG
+6 -6
View File
@@ -123,15 +123,15 @@ def test_메뉴_모든_줄():
# 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음)
assert out["sec:1"] == [*add, "-", "아래로 ↓"]
# 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음)
assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-",
assert out["earth:4"] == [*add, "-", "단가연결", "단가연결 풀기", "-",
"아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓",
assert out["earth:5"] == [*add, "-", "단가연결", "-", "위로 ↑", "아래로 ↓",
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
"줄 삭제"] # fmt: skip
assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←",
"항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip
assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
assert out["hand:a"] == [*add, "-", "단가연결", "단가연결 풀기", "-", "내어쓰기 ←",
"묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 ·
# 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도)
@@ -174,7 +174,7 @@ def test_삭제_옮김_새_공종_자동값으로():
("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"),
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"),
("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip
("earth:4", "단가연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip
out = _menu(user, runs=runs)["runs"]
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
@@ -215,7 +215,7 @@ def test_삭제_옮김_새_공종_자동값으로():
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
# 자동 줄 연결 풀기 = ref 덮개 null
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
assert {e["key"]: e for e in out["earth:4|단가연결 풀기"]["rows"]}["earth:4"]["ref"] is None
# 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠)
got = out["fee|항목 추가"]["rows"]
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
@@ -270,7 +270,7 @@ def test_표_칸_강조_번호_연결_칸():
)
out = json.loads(_node(_ts([*SRC, "UI_Tabs_Bill"]) + body))
assert out["names"] == ["1 토공사", "1.1 절토", "earth:4"] # 번호 = 셈 no
assert out["connect"] == ["연결됨 ▾", "연결 ▾", "대가3 ▾", "", "", ""]
assert out["connect"] == ["연결됨 ▾", "단가연결 ▾", "대가3 ▾", "", "", ""]
assert out["cls"] == ["is-edited", None, None]
assert out["tip"] == "자동 값 5 — 우클릭 [자동값으로 되돌리기]"
assert out["edit"] == [True, False, True, True, False]
+15 -15
View File
@@ -188,10 +188,11 @@ globalThis.showToast = (m, k) => toasts.push([k, m]);
globalThis.API_BASE_URL = "/api";
globalThis.CURRENT_PROJECT_ID_KEY = "pid";
globalThis.localStorage = { getItem: () => "P1" };
globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]);
lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); };
globalThis.openItemPicker = async () => ({ action: "apply",
item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } });
let picker = null; // 251-6 단가연결 = 한 창(openLogicPicker · 사전조합 + 덧붙인 줄 구조물 조합)
globalThis.openLogicPicker = (done, lib) => { picker = lib;
sent.push(["pre", lib.layer, lib.kindFilter.map((k) => k.value), lib.title]); };
globalThis.loadRows = async () => [{ key: "SS000011", layer: "system", owner: null, 단위: "m" }];
globalThis.itemName = () => "관매설";
const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new",
payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } },
sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } };
@@ -202,9 +203,12 @@ const applied = [];
const apply = (ref, from) => applied.push([ref, from && from.name]);
const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply);
const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) };
picks.find((x) => x[0] === "일위대가 사전조합")[1]();
picks[0][1]();
await new Promise((r) => setTimeout(r, 0));
picks.find((x) => x[0] === "구조물 조합")[1]();
out.combo = picker.extra.filter((x) => x.kind === "combo").map((x) => [x.tag, x.name, x.sub]);
picker.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합");
await new Promise((r) => setTimeout(r, 0));
picker.extra.find((x) => x.kind === "combo").onPick();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
Object.assign(out, { sent, applied, payload: ctx.payload, toasts });
@@ -223,16 +227,12 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
) # fmt: skip
assert result.returncode == 0, result.stderr
out = json.loads(result.stdout)
assert out["picks"] == [
"일위대가 호표",
"단가산출 호표",
"구조물 조합",
"일위대가 사전조합",
"단가산출",
] # 246-1 이름 셋(서고 · 층 · M01 글 없음)
assert out["more"] == ["연결 풀기"]
assert out["picks"] == ["단가연결"] # 251-6 고르기 다섯 = 단가연결 창 하나
assert out["more"] == ["단가연결 풀기"]
pre, (url, body) = out["sent"][0], out["sent"][1]
assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋
kinds = ["logic", "precombo", "combo", "dg", "sg"]
assert pre == ["pre", "personal", kinds, "단가연결"] # 층 셋 단추 · 종류 고르기
assert out["combo"] == [["구조물 조합", "관매설", "마스터 · SS000011"]]
assert url == "/api/projects/P1/estimation/library"
assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"}
assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None,
@@ -31,11 +31,12 @@ def test_더함_하나_줄_삭제_연결_이름_셋():
assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key]
assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"]
link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8")
picks = link.split("const picks: Leaf[] = [")[1].split("];")[0]
# 251-6 고르기 다섯 = 단가연결 모달 하나 · 종류 이름 = 단가산출 · 사전조합 · 구조물 조합 · 호표 둘
kinds = link.split("kindFilter: [")[1].split("],\n pick")[0]
for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]:
assert f'["{name}",' in picks
assert f'text: "{name}"' in kinds
for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]:
assert word not in picks
assert word not in kinds
toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]]
assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말
bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
@@ -165,4 +165,4 @@ def test_옛_아코디언_없음_공용_꼴():
assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css
# B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로)
edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8")
assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit
assert "parts[1].push(...picks, ...more);" in edit # 251-6 단가연결 = 모달 하나(하위 메뉴 없음)