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
@@ -8,7 +8,7 @@
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* 「단가연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
* ========================================================================== */
@@ -37,7 +37,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const rows: DataRow[] = raw.map((r: Row) => ({
...r,
name: r.no ? `${r.no} ${r.name}` : r.name,
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "연결"} ▾` : "",
connect: linkable(r) ? `${!r.auto && r.note ? r.note : r.ref ? "연결됨" : "단가연결"} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
@@ -57,14 +57,14 @@ export const buildBill: TabBuild = (ctx): BillTable => {
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("connect", "연결", 96),
text("connect", "단가연결", 96),
text("note", "비고", 200),
],
rows,
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「단가연결 ▾」로 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
: ""),
@@ -252,8 +252,8 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i);
parts[1].push({ label: "연결", children: picks }, ...more);
const [picks, more] = linkFor(ctx, rows, i); // 251-6 단가연결 = 모달 하나(옛 하위 메뉴 다섯)
parts[1].push(...picks, ...more);
}
// 옮김 · 레벨 — 같은 부모 형제와 자손째
if (!fixed(key)) {
@@ -355,7 +355,8 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !linkable(rows[i])) return;
const [picks, more] = linkFor(ctx, rows, i);
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
if (!more.length) return picks[0][1](); // 251-6 아직 연결 없음 = 단가연결 모달 바로
shared.open(box.left, box.bottom, [...picks, "-", ...more]);
}
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
@@ -4,6 +4,8 @@
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
* 연결 풀기.
* 251 단가연결 = 한 모달(`openPriceLink` · 종류 고르기 + 검색) — B09 설계내역서 · B08 거리이정표 같이 씀 ·
* 단가산출 = 옵션 값 창(`askInputs` · 160)
*
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
@@ -12,9 +14,8 @@
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { openItemPicker } from "@ui/ui_template_item_picker";
import { openModal } from "@ui/ui_template_modal";
import { loadRows } from "@ui/ui_template_item_picker";
import { itemName, type PickerLayer } from "@ui/ui_template_item_picker_data";
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick";
@@ -24,47 +25,14 @@ import type { TabContext } from "./B09_Estimation_UI_Tabs";
/** 연결 적용 — ref = null 이면 풀기 · from = 고른 호표(명칭 · 규격 · 단위 채움) */
export type ApplyRef = (ref: Row | null, from?: Row) => void;
export type Leaf = [string, () => void];
/** 있는 호표 고르기 창 — 일위대가목록표(dg) · 단가산출근거목록표(sg) */
function pickHopyo(ctx: TabContext, apply: ApplyRef, tab: "dg" | "sg"): void {
const listing: Row[] = (tab === "dg" ? ctx.sheets.daega : ctx.sheets.sg_list) || [];
if (!listing.length) return showToast("고를 호표 없음 — 연결된 구조물 조합 사본이 없음", "error");
const label = (r: Row): string => (tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`);
openModal({
title: tab === "dg" ? "일위대가 호표 고르기" : "단가산출 호표 고르기",
closeLabel: "닫기",
mount: (body, close) => {
const finder = createFindList({ placeholder: "호표 · 명칭 · 규격 찾기" });
const find = (): void => {
const q = finder.search.value.trim().toLowerCase();
const found = listing.filter(
(r) => !q || `${label(r)} ${r.name} ${r.spec}`.toLowerCase().includes(q),
);
finder.hint.textContent = `${found.length} / ${listing.length}`;
finder.setRows(
found.map((r) => ({
key: String(r.key),
tag: label(r),
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => {
close();
apply({ tab, key: r.key }, r);
},
})),
);
};
finder.search.addEventListener("input", find);
find();
body.append(finder.root);
requestAnimationFrame(() => finder.search.focus());
},
});
}
const LAYER_TEXT: Record<PickerLayer, string> = {
system: "마스터",
company: "회사",
personal: "개인",
};
/** M01 로직 → 입력값 창 → 연결(새 산근 · 단가는 [저장] 뒤 서버 셈) */
function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void {
fetchLogicInputs([code]).then(
(got) => {
const logic = got.로직[0] ?? null;
@@ -94,10 +62,10 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
async function importLib(
ctx: TabContext,
apply: ApplyRef,
o: { apply: ApplyRef; onPayload?: (payload: Row) => void },
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
): Promise<void> {
const apply = o.apply;
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return;
showToast("가져오는 중…", "info");
@@ -115,7 +83,7 @@ async function importLib(
}
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
const { user: _user, ...rest } = got.payload as Row;
Object.assign(ctx.payload, rest);
o.onPayload?.(rest);
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
showToast(
@@ -127,39 +95,79 @@ async function importLib(
}
}
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
void openItemPicker({ current: null, canCopy: false }).then((got) => {
if (got.action !== "apply") return;
const { layer, owner, key } = got.item;
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
});
}
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
openLogicPicker(() => undefined, {
layer: "personal",
owner: null,
libOnly: true,
title: "일위대가 사전조합 고르기",
pick: (ref) =>
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
});
}
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
/** 항목 줄 단가연결 메뉴(251-6) — [단가연결 모달, 그 밖(로직 입력값 · 단가연결 풀기)] · ref = 지금 연결 */
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(apply, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => apply(null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
["구조물 조합", () => pickCombo(ctx, apply)],
["일위대가 사전조합", () => pickPrecombo(ctx, apply)],
["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))],
if (ref) more.push(["단가연결 풀기", () => apply(null)]);
const open: Leaf = [
"단가연결",
() =>
openPriceLink({
apply,
hopyo: { dg: ctx.sheets.daega || [], sg: ctx.sheets.sg_list || [] },
onPayload: (p) => Object.assign(ctx.payload, p),
}),
];
return [picks, more];
return [[open], more];
}
/** 단가연결 모달(251 · 159 · 160) — 한 목록에 단가산출 로직 · 일위대가 사전조합 · 구조물 조합(마스터 · 회사 ·
* 개인 층 단추) · 호표(B09 · 있을 때) · 종류 고르기 + 검색 + 구분 · 상세구분 거름 ·
* 단가산출 = 옵션 값 창(`askInputs` · 160) · 사전조합 · 구조물 조합 = 프로젝트 사본 들여옴 → `{tab: dg, key, lib}` */
export interface PriceLinkOpts {
apply: ApplyRef;
/** B09 호표 목록(일위대가 · 단가산출) — 없으면 그 종류 없음(B08) */
hopyo?: { dg: Row[]; sg: Row[] };
/** 서고 사본 들여온 뒤 받은 원가 자료(B09 = 화면 자료에 끼움) */
onPayload?: (payload: Row) => void;
}
export async function openPriceLink(o: PriceLinkOpts): Promise<void> {
const hop = (tab: "dg" | "sg") =>
((tab === "dg" ? o.hopyo?.dg : o.hopyo?.sg) ?? []).map((r) => ({
kind: tab,
key: String(r.key),
tag: tab === "dg" ? `대가${r.no}` : `산근 ${r.no}호표`,
name: String(r.name || ""),
sub: String(r.spec || ""),
unit: String(r.unit || ""),
onPick: () => o.apply({ tab, key: r.key }, r),
}));
// 구조물 조합 = 마스터 · 회사 · 개인 한 목록(B08 [선택] 나무와 같은 원천)
const combos = (await loadRows().catch(() => [])).map((r) => ({
kind: "combo",
key: `${r.layer}:${r.owner ?? ""}:${r.key}`,
tag: "구조물 조합",
name: itemName(r),
sub: `${LAYER_TEXT[r.layer ?? "system"]} · ${r.key}`,
unit: String(r.단위 || ""),
onPick: () =>
void importLib(o, {
kind: "combo",
layer: r.layer ?? "system",
owner: r.owner ?? null,
key: r.key,
}),
}));
openLogicPicker((logic) => askInputs(o.apply, logic.키, logic.이름), {
layer: "personal",
owner: null,
title: "단가연결",
extra: [...combos, ...hop("dg"), ...hop("sg")],
kindFilter: [
{ value: "logic", text: "단가산출" },
{ value: "precombo", text: "일위대가 사전조합" },
{ value: "combo", text: "구조물 조합" },
...(o.hopyo
? [
{ value: "dg", text: "일위대가 호표" },
{ value: "sg", text: "단가산출 호표" },
]
: []),
],
pick: (ref) =>
void importLib(o, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
});
}
@@ -3,6 +3,8 @@
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류)
* 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) +
* 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter`
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
@@ -38,6 +40,18 @@ export interface LibraryPick {
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
libOnly?: boolean;
title?: string;
/** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */
extra?: Array<{
kind: string;
key: string;
tag: string;
name: string;
sub: string;
unit: string;
onPick: () => void;
}>;
/** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */
kindFilter?: Array<{ value: string; text: string }>;
}
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
@@ -72,12 +86,21 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
let scopes: Scope[] = [];
let scope: Scope | null = null;
let libRows: { kind: "Kind_Combo"; row: LibraryRow }[] = [];
// 251 종류 고르기 — 값 "" = 전체 · logic · precombo · 덧붙인 줄 kind
const kindSel = lib?.kindFilter
? createSelectField({
options: [{ value: "", text: L("M01_All") }, ...lib.kindFilter],
compact: true,
label: "종류",
})
: null;
const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k;
let loading = false;
let seq = 0;
/* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */
const finder = createFindList({
placeholder: tc("Pick_Search"),
filters: [sub.root, detail.root],
filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root],
extra: [layerBar],
});
@@ -85,18 +108,26 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
const q = finder.search.value.trim().toLowerCase();
const found = all.filter(
(x) =>
kindOk("logic") &&
(!sub.select.value || x.구분 === sub.select.value) &&
(!detail.select.value || x.상세구분 === detail.select.value) &&
matchLogic(finder.search.value, x).ok, // 이름 · 키 · 별칭 …(공용 · 208-1)
);
const libFound = libRows.filter(
(x) =>
kindOk("precombo") &&
(!sub.select.value || x.row.구분 === sub.select.value) &&
(!detail.select.value || x.row.상세구분 === detail.select.value) &&
(!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length;
const shown = found.length + libFound.length;
const extra = (lib?.extra ?? []).filter(
(x) =>
kindOk(x.kind) &&
!sub.select.value &&
(!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)),
);
const total = all.length + libRows.length + (lib?.extra?.length ?? 0);
const shown = found.length + libFound.length + extra.length;
finder.hint.textContent = loading
? tc("Loading")
: shown
@@ -132,6 +163,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
},
};
}),
...extra.map((x) => ({
...x,
onPick: () => {
close();
x.onPick();
},
})),
]);
};
const loadLib = async (): Promise<void> => {
@@ -192,6 +230,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
find();
});
detail.select.addEventListener("change", find);
kindSel?.select.addEventListener("change", find);
finder.search.addEventListener("input", find);
layerBar.style.display = "none";
+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 단가연결 = 모달 하나(하위 메뉴 없음)