- 좌측 차례 = 기본정보 › 시험 계산 › 단가산출 상세 › 텍스트 수식 - 오른쪽 패널 둘(흐름도 · 원문) — 하나만 열림 · 둘 다 접기 · 머리 토글 단추 · 열린 패널 계정 취향(m01-logic-panel) - 흐름도 패널 접힘을 부르는 쪽이 정하는 길(folded · onFold · fold) - 원문 패널 = 출처 글로 품셈 본문 절을 찾아 보임(GET /api/m01/logic/source) · 못 찾으면 「원문 연결 없음」 + 출처 - 시험 test_m01_logic_source.py · 화면 시나리오 ui_checks/44-4_로직상세_패널.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_LogicLab_Side.ts
|
|
* 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절)
|
|
* 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로)
|
|
* 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
|
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
|
|
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
|
|
import "./M01_MasterData_UI_LogicLab_Side.css";
|
|
|
|
const TEXT = {
|
|
Toggle: ["원문", "Source"],
|
|
Open: ["원문 펴기", "Open the source panel"],
|
|
Close: ["원문 접기", "Collapse the source panel"],
|
|
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);
|
|
|
|
/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */
|
|
const STRIP = 40;
|
|
|
|
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 {
|
|
/** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · setRight = 흐름도 패널이 알린 너비 */
|
|
flowSplit: SplitHandle;
|
|
flowFolded(): boolean;
|
|
pick(next: PanelPick): void;
|
|
/** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */
|
|
bindFlow(fold: ((on: boolean) => void) | null): void;
|
|
/** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */
|
|
showSource(src: string): void;
|
|
}
|
|
|
|
const readPick = (): PanelPick => {
|
|
const got = readStateRaw("m01-logic-panel");
|
|
return got === "source" || got === "none" ? got : "flow";
|
|
};
|
|
|
|
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(split: SplitHandle): 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 toggle = createButton({
|
|
label: ts("Toggle"),
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => pick(now === "source" ? "none" : "source"),
|
|
});
|
|
toggle.classList.add("m02-dfp__toggle");
|
|
const head = el("header", { className: "m02-dfp__head", children: [toggle, title] });
|
|
const open = el("div", { className: "m02-dfp__open", children: [head, body] });
|
|
const strip = el("div", { className: "m02-dfp__strip" });
|
|
const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] });
|
|
const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] });
|
|
split.right.replaceChildren(
|
|
el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }),
|
|
);
|
|
|
|
let now = readPick();
|
|
let flowPx: number | null = null;
|
|
let flowFold: ((on: boolean) => void) | null = null;
|
|
let want = "";
|
|
let shown: string | null = null;
|
|
|
|
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */
|
|
const width = (): void =>
|
|
split.setRight(
|
|
now === "source" || (now === "flow" && flowPx === null)
|
|
? null
|
|
: now === "flow"
|
|
? (flowPx ?? 0) + STRIP
|
|
: STRIP * 2,
|
|
);
|
|
|
|
const load = (): void => {
|
|
if (now !== "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),
|
|
]),
|
|
);
|
|
});
|
|
};
|
|
|
|
const fold = (on: boolean): void => {
|
|
open.hidden = on;
|
|
strip.hidden = !on;
|
|
(on ? strip : head).prepend(toggle);
|
|
toggle.setAttribute("aria-expanded", String(!on));
|
|
toggle.title = ts(on ? "Open" : "Close");
|
|
};
|
|
|
|
const pick = (next: PanelPick): void => {
|
|
if (next !== now) writeStateRaw("m01-logic-panel", next);
|
|
now = next;
|
|
flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠
|
|
flowSlot.classList.toggle("is-open", now === "flow");
|
|
sourceSlot.classList.toggle("is-open", now === "source");
|
|
fold(now !== "source");
|
|
width();
|
|
load();
|
|
};
|
|
|
|
pick(now);
|
|
return {
|
|
flowSplit: {
|
|
root: split.root,
|
|
right: flowSlot,
|
|
setRight: (px) => {
|
|
flowPx = px;
|
|
width();
|
|
},
|
|
},
|
|
flowFolded: () => now !== "flow",
|
|
pick,
|
|
bindFlow: (f) => {
|
|
flowFold = f;
|
|
},
|
|
showSource: (src) => {
|
|
want = src;
|
|
load();
|
|
},
|
|
};
|
|
}
|