feat(M02): 47-4 일위대가 상세 오른쪽 「원문」 접는 패널 — 고른 줄 로직의 품셈 원문

- 공용 오른쪽 접는 패널(ui_template_side_panels · 47-3 부품) · 취향 키 m02-labor-side · 카드 갈래엔 없음
- 고른 줄 없음 = 안내 한 줄 · 고르면 그 로직 출처 원문(GET /m01/logic/source)
- 원문 보기를 M01_MasterData_UI_LogicLab_Source 로 떼어 M01 로직 상세와 한 벌로 씀(M01 동작 그대로)
- 입력 목록 길(GET /m02/logic-lib/inputs)에 로직 출처 칸 추가
- 시험: test_m02_labor_values 출처 확인 · 화면 시나리오 ui_checks/47-4_일위대가_원문패널.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-05 13:45:53 +09:00
co-authored by Claude Opus 5.5
parent 5f5da8119b
commit 66cb7afbab
8 changed files with 249 additions and 100 deletions
@@ -2,45 +2,26 @@
* M01_MasterData_UI_LogicLab_Side.ts
* 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 둘 「흐름도」(M02_DF_Panel) · 「원문」(품셈 본문 절)
* 공용 오른쪽 접는 패널(ui_template_side_panels) — 하나만 열림 · 모두 접힘 됨 · 폭 공유 · 접힌 패널 = 세로 글 띠
* 열린 패널 · 폭은 취향 키(`m01-logic-side`) · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
* 열린 패널 · 폭은 취향 키(`m01-logic-side`) · 원문 = 공용 원문 보기(`LogicLab_Source`)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
import { createSourceView } from "./M01_MasterData_UI_LogicLab_Source";
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
import "./M01_MasterData_UI_LogicLab_Side.css";
const TEXT = {
Flow: ["흐름도", "Flow"],
Source: ["원문", "Source"],
Loading: ["원문 찾는 중", "Looking up the source"],
None: ["원문 연결 없음", "No linked source"],
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export type PanelPick = "flow" | "source" | "none";
interface Block {
h?: number;
text?: string;
p?: string;
table?: string[][];
}
interface Found {
number: string;
matched: string;
file: string;
blocks: Block[];
}
export interface LogicSide {
/** [왼쪽 지금 내용, 오른쪽 패널] 칸 */
root: HTMLElement;
@@ -54,42 +35,16 @@ export interface LogicSide {
showSource(src: string): void;
}
function blockNode(b: Block): HTMLElement {
if (b.table)
return el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab-src__table",
children: [
el("tbody", {
children: b.table.map((r) =>
el("tr", { children: r.map((c) => el("td", { text: c })) }),
),
}),
],
}),
],
});
if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" });
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
}
export function buildLogicSide(left: HTMLElement): LogicSide {
const flowSlot = el("div", { className: "m01lab-side__slot" });
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const source = createSourceView();
const sp = createSidePanels({
prefKey: "m01-logic-side",
defaultWidth: 520,
minWidth: 320,
panels: [
{ id: "flow", title: ts("Flow"), body: flowSlot },
{
id: "source",
title: ts("Source"),
body: el("div", { className: "m01lab-src", children: [title, body] }),
},
{ id: "source", title: ts("Source"), body: source.root },
],
onChange: () => {
flowFold?.(sp.current() !== "flow"); // 다시 펴면 흐름도 판을 폭에 맞춤
@@ -103,37 +58,10 @@ export function buildLogicSide(left: HTMLElement): LogicSide {
let flowFold: ((on: boolean) => void) | null = null;
let want = "";
let shown: string | null = null;
/** 원문 패널이 열려 있을 때만 찾음 */
const load = (): void => {
if (sp.current() !== "source" || shown === want) return;
const src = (shown = want);
title.textContent = src || ts("NoSource");
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
el("p", { className: "m01lab-src__none", text: ts("None") }),
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
if (shown !== src) return;
if (!found.length) return none();
body.replaceChildren(
...found.flatMap((f) => [
el("p", {
className: "m01-logic__muted m01lab-src__file",
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
}),
...f.blocks.map(blockNode),
]),
);
});
if (sp.current() === "source") source.show(want);
};
return {
@@ -0,0 +1,101 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Source.ts
* 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
import "./M01_MasterData_UI_LogicLab_Side.css";
const TEXT = {
Loading: ["원문 찾는 중", "Looking up the source"],
None: ["원문 연결 없음", "No linked source"],
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
interface Block {
h?: number;
text?: string;
p?: string;
table?: string[][];
}
interface Found {
number: string;
matched: string;
file: string;
blocks: Block[];
}
function blockNode(b: Block): HTMLElement {
if (b.table)
return el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab-src__table",
children: [
el("tbody", {
children: b.table.map((r) =>
el("tr", { children: r.map((c) => el("td", { text: c })) }),
),
}),
],
}),
],
});
if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" });
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
}
export interface SourceView {
root: HTMLElement;
/** 그 출처 글의 원문을 그림 — 지금 보이는 것과 같으면 안 부름 */
show(src: string): void;
}
export function createSourceView(): SourceView {
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
let shown: string | null = null;
const show = (src: string): void => {
if (shown === src) return;
shown = src;
title.textContent = src || ts("NoSource");
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
el("p", { className: "m01lab-src__none", text: ts("None") }),
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
if (shown !== src) return;
if (!found.length) return none();
body.replaceChildren(
...found.flatMap((f) => [
el("p", {
className: "m01-logic__muted m01lab-src__file",
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
}),
...f.blocks.map(blockNode),
]),
);
});
};
return { root: el("div", { className: "m01lab-src", children: [title, body] }), show };
}
+2 -1
View File
@@ -41,7 +41,7 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
out = []
for logic in logics:
@@ -57,6 +57,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"로직": logic.get("키"),
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"출처": logic.get("출처"),
"입력": specs,
}
)
@@ -313,6 +313,8 @@ export interface ItemLogic {
로직: string;
이름: string;
결과단위?: string;
/** 품셈 출처 글(47-4 · 원문 패널) */
출처?: string | null;
입력: ItemInput[];
}
@@ -6,12 +6,15 @@
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
import {
@@ -108,6 +111,26 @@ export function mountLaborDetail(
children: [el("summary", { text: diagramTitle() }), dgHost],
});
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
const source = createSourceView();
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = opt.card
? null
: createSidePanels({
prefKey: "m02-labor-side",
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
onChange: () => showSource(),
});
function showSource(): void {
if (side?.current() !== "source") return;
if (!picked)
return void sourceHost.replaceChildren(
el("p", { className: "m01-logic__muted", text: tc("Labor_SourceHint") }),
);
source.show(logics.get(picked)?.출처 ?? "");
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
}
const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key);
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
@@ -157,6 +180,7 @@ export function mountLaborDetail(
),
);
drawDg();
showSource();
};
/** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */
@@ -269,11 +293,12 @@ export function mountLaborDetail(
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
changed();
}
/** 줄 고름(47-5) — 하이라이트만(표만 · 다이어그램 안 다시 그림) · 화면 캐시 안 바뀜 */
/** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */
function pick(i: number): void {
picked = cur.담은로직[i].로직;
for (const tr of logicHost.querySelectorAll<HTMLElement>("[data-labor-logic],[data-labor-opt]"))
tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked);
showSource();
}
function removeRow(i: number): void {
if (cur.담은로직[i].로직 === picked) picked = null;
@@ -390,26 +415,30 @@ export function mountLaborDetail(
],
});
const editor = (body: HTMLElement): HTMLElement =>
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
host.replaceChildren(
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
...(opt.card
? []
: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
],
}),
]),
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), addMat, matHost),
...(opt.card ? [] : [dgBox]),
],
}),
editor(
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
...(opt.card
? []
: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
],
}),
]),
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), addMat, matHost),
...(opt.card ? [] : [dgBox]),
],
}),
),
);
draw();
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
@@ -35,6 +35,11 @@ const TEXT = {
Down: ["아래로", "Down"],
Remove: ["삭제", "Delete"],
Labor_Option: ["옵션", "Options"],
Labor_Source: ["원문", "Source"],
Labor_SourceHint: [
"단가산출 로직 줄을 고르면 그 원문이 보임",
"Pick a logic row to see its source",
],
AddLogic: ["로직 추가", "Add logic"],
NoLogic: ["단가산출 로직 없음", "No logics yet"],
Open: ["열기", "Open"],
@@ -43,6 +43,7 @@ def test_입력_목록은_키_차례_겹침_한_번_자동값_빠진_로직(clie
assert got["빠진로직"] == ["GF999999"]
one = got["로직"][1]
assert one["이름"] and one["결과단위"]
assert all("출처" in x for x in got["로직"]) # 47-4 원문 패널
assert one["입력"] and all("자동값" in i and "이름" in i for i in one["입력"])
# 입력 목록 몸 한 벌(M02_Labor_Inputs)
assert got["로직"] == labor_inputs.logic_inputs([B, A])["로직"]
@@ -0,0 +1,82 @@
/* 47-4 — 일위대가 상세 오른쪽 「원문」 접는 패널(공용 ui_template_side_panels) · 고른 줄 로직의 원문
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
* 나무에 item1(구조물 조합 · 일위대가 로직 담김)이 있음
* 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: item1 카드(패널 없음) → 일위대가 [수정] 상세 → 패널 펴기 → 줄 고르기 → 접고 폄 → 처음 패널 상태로 → item1 다시
* 기대 수치:
* - cardPanel = 0(카드 갈래엔 패널 없음) · detailPanel = 1
* - hint = 「단가산출 로직 줄을 고르면 그 원문이 보임」(고른 줄 없음)
* - picked.title = 그 로직 출처 글(예 「건설품셈 공통 6-3-3」) · picked.blocks > 0(원문 절 글 · 표) · picked.loading = false
* - second.title ≠ picked.title 이면 다른 로직 원문으로 바뀜(출처가 같으면 같음)
* - folded = { panel: true(숨음) , strip: false(띠 보임) } · reopened.title = second.title(접고 펴도 그대로)
* - restored = 처음 패널 상태 · back = true
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 150) {
const v = fn();
if (v) return v;
await w(150);
}
return null;
};
const node = await until(() => document.querySelector('[data-node="i|item1"]'));
if (!node) return JSON.stringify({ error: "item1 없음 — 폴더를 펼쳐 둘 것" });
node.click();
const card = await until(() =>
[...document.querySelectorAll(".m02-struct__card")].find((c) =>
c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"),
),
);
if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" });
const out = { cardPanel: card.querySelectorAll(".ui-side-panels").length };
[...card.querySelectorAll(".m02-struct__card-tools button")]
.find((b) => b.innerText.trim() === "수정")
?.click();
const row = await until(() =>
document.querySelector(".ui-side-panels-row .m02-labor__edit")?.closest(".ui-side-panels-row"),
);
if (!row) return JSON.stringify({ ...out, error: "상세 패널 칸 없음" });
await until(() => row.querySelector("tbody tr[data-labor-logic]"));
const sp = row.querySelector(".ui-side-panels");
out.detailPanel = row.querySelectorAll(":scope > .ui-side-panels").length;
const box = sp.querySelector('.ui-side-panels__panel[data-panel="source"]');
const strip = sp.querySelector(".ui-side-panels__strip");
const wasOpen = !box.hidden;
if (!wasOpen) strip.click();
await w(200);
const host = () => row.querySelector("[data-labor-source]");
out.hint = host().innerText.trim();
const read = async () => {
await until(() => !/원문 찾는 중/.test(host().innerText), 8000);
return {
title: host().querySelector(".m02-dfp__title")?.innerText.trim() ?? null,
blocks: host().querySelectorAll(".m01lab-src__p, .m01lab-src__head, .m01lab-src__table")
.length,
loading: /원문 찾는 중/.test(host().innerText),
none: !!host().querySelector(".m01lab-src__none"),
};
};
const rows = () => [...row.querySelectorAll("tbody tr[data-labor-logic]")];
rows()[0].querySelector(".m02-labrow__name").click();
out.picked = { key: rows()[0].dataset.laborLogic, ...(await read()) };
const two = rows()[1];
if (two) {
two.querySelector(".m02-labrow__name").click();
out.second = { key: two.dataset.laborLogic, ...(await read()) };
}
sp.querySelector(".ui-side-panels__fold").click();
await w(200);
out.folded = { panel: box.hidden, strip: strip.hidden };
strip.click();
await w(300);
out.reopened = await read();
if (!wasOpen) sp.querySelector(".ui-side-panels__fold").click();
await w(200);
out.restored = !box.hidden === wasOpen;
document.querySelector('[data-node="i|item1"]')?.click();
await w(1200);
out.back = !!document.querySelector(".m02-struct__card table.m02-labrow");
return JSON.stringify(out);
})();