Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-01 11:57:37 +09:00
6 changed files with 1942 additions and 0 deletions
@@ -0,0 +1,199 @@
.m01-lbw {
--modal-max: 90vh;
width: min(980px, 96vw);
font-size: var(--text-body-lg, 1.05rem);
}
.m01-lbw__ask {
margin: 0;
font-size: 1.4em;
color: var(--color-text);
}
.m01-lbw__cols {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(220px, 1fr);
gap: var(--spacing-12);
align-items: start;
}
@media (max-width: 760px) {
.m01-lbw__cols {
grid-template-columns: minmax(0, 1fr);
}
}
.m01-lbw__main,
.m01-lbw__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01-lbw__side {
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-lbw h5 {
margin: var(--spacing-8) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-lbw__muted {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-lbw__list,
.m01-lbw__tables,
.m01-lbw__roles,
.m01-lbw__added,
.m01-lbw__notes,
.m01-lbw__stack {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-lbw__list {
max-height: 24vh;
overflow: auto;
}
.m01-lbw__row,
.m01-lbw__note,
.m01-lbw__chip,
.m01-lbw__role {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border: 2px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-lbw__row {
flex-direction: column;
gap: 2px;
}
.m01-lbw__row.is-on,
.m01-lbw__note.is-on,
.m01-lbw__chip.is-on {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent);
}
.m01-lbw__row:hover,
.m01-lbw__note:hover,
.m01-lbw__chip:hover,
.m01-lbw__role:hover,
.m01-lbw__row:focus-visible,
.m01-lbw__chip:focus-visible,
.m01-lbw__role:focus-visible,
.m01-lbw__note:focus-visible {
border-color: var(--color-accent);
}
.m01-lbw__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-lbw__chip {
padding: var(--spacing-4) var(--spacing-12);
border-radius: var(--radius-pills);
}
.m01-lbw__rolebox {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m01-lbw__role {
flex-direction: column;
min-width: 120px;
}
.m01-lbw__role span {
font-size: var(--text-caption);
}
.m01-lbw__role.is-조건 {
border-color: var(--color-accent);
}
.m01-lbw__role.is-고정 {
border-style: dashed;
}
.m01-lbw__role.is-쓰지\ 않음 {
opacity: 0.6;
}
.m01-lbw__line {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
}
.m01-lbw__form,
.m01-lbw__extras {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
}
.m01-lbw__item {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m01-lbw__link {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.m01-lbw__scroll {
overflow: auto;
max-height: 22vh;
}
.m01-lbw__table table {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-lbw__table th,
.m01-lbw__table td {
padding: 2px 8px;
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: center;
}
.m01-lbw__table th.is-조건 {
background: var(--color-mist-violet, rgba(120, 100, 220, 0.18));
}
.m01-lbw__table th.is-고정 {
background: rgba(200, 160, 40, 0.2);
}
.m01-lbw__table th.is-결과 {
background: rgba(60, 160, 100, 0.2);
}
.m01-lbw__note-line {
margin: 2px 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-lbw__nav {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: flex-end;
}
.m01-lbw__nav > :first-child {
margin-right: auto;
}
.m01-lbw__result {
margin: 0;
color: var(--color-accent);
font-size: 1.15em;
font-weight: 600;
}
@@ -0,0 +1,628 @@
/* =============================================================================
* M01_LB_Wizard.ts
* 로직 만들기 판 — 단계 안내 6단계 · openWizard() → LbLogic | null(취소) · 저장은 Entry 몫
* ① 어느 품셈 절 ② 표 칸 역할 ③ 결과 단위 ④ 계산(Calc.ts) ⑤ 항목 연결(Calc.ts) ⑥ 이름 · 결과 종류
* 답은 Build.ts 가 로직 한 줄로 짜고 Blocks.readLogic 이 블록으로 읽음 · 답할 때마다 곁에 예시값
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { type CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
import { calcLater, plainError } from "../EasyCalc/M01_EasyCalc_Api";
import { readLogic, type LbLogic } from "./M01_LB_Blocks";
import { autoLinks, stepCalc, stepLink } from "./M01_LB_Wizard_Calc";
import {
buildRow,
colsOf,
trialOf,
unitOfBase,
type Role,
type WAnswers,
type WCtx,
type WTable,
} from "./M01_LB_Wizard_Build";
import {
get,
tablePreview,
toTable,
type SectionItem,
type TableHead,
} from "./M01_LB_Wizard_Parts";
import "./M01_LB_Wizard.css";
const TEXT = {
Title: ["로직 만들기 — 단계 안내", "Build a logic — step by step"],
Close: ["닫기", "Close"],
Back: ["← 뒤로", "← Back"],
Next: ["다음 →", "Next →"],
Make: ["만들기", "Create"],
StepOf: ["{n} / 6 단계", "Step {n} of 6"],
Q1: ["어느 품셈 절인가요?", "Which section of the standard?"],
Q2: ["표의 칸마다 무엇인가요?", "What is each column?"],
Q3: ["결과는 무슨 단위인가요?", "What unit is the result?"],
Q4: ["더하거나 나눌 것이 있나요?", "Anything to add or divide?"],
Q5: ["값은 어느 항목으로 가나요?", "Where does each value go?"],
Q6: ["이름과 결과 종류", "Name and kind of result"],
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름을 쳐서 찾기 — 예: 13-4-1 메쌓기", "Search — e.g. 13-4-1"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개 · 만든 로직 {l} 개", "{t} tables · {l} logics"],
Tables: ["이 절의 표 — 하나를 고름", "Tables in this section — pick one"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
Notes: ["주", "Notes"],
Preview: ["원문 표 미리 보기", "Table preview"],
RoleHint: [
"칸 머리를 누르면 조건 → 고정 → 결과 로 바뀜 · 조건 칸의 선택 목록은 표 줄에서 저절로 만듦",
"Press a header to change its role",
],
Cond: ["조건", "Condition"],
Fixed: ["고정", "Fixed"],
Result: ["결과", "Result"],
Unused: ["쓰지 않음", "Unused"],
Choices: ["선택 목록", "Choices"],
Free: ["숫자 입력", "Number input"],
PickTable: ["표를 하나 골라 주세요", "Pick a table"],
NeedResult: ["결과 칸이 하나는 있어야 함", "At least one result column"],
FixedVal: ["고정값", "Fixed value"],
BaseUnit: ["표 기준 「{b}」", "Table base “{b}”"],
UnitPick: ["결과단위", "Result unit"],
Name: ["이름", "Name"],
KindMoney: ["1 {p} 당 금액", "Amount per {p}"],
KindValue: ["다른 로직이 쓸 값", "A value other logics use"],
ValueCol: ["내보낼 값(결과 칸)", "Value to give"],
ValueUnit: ["값의 단위", "Unit of the value"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
Sample: ["예시값", "Sample values"],
SampleHint: ["값을 바꾸면 곁의 결과가 바로 바뀜", "Change a value to see the result"],
Waiting: ["계산 중…", "Calculating…"],
AmountPer: ["1 {p} 당 약 {m} 원", "About {m} won per {p}"],
ValueIs: ["{c} = {v} {u}", "{c} = {v} {u}"],
NotYet: ["아직 계산할 것이 없음", "Nothing to calculate yet"],
Fail: ["읽지 못함", "Could not read"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR");
const LBL = { preview: t("Preview"), notes: t("Notes") };
const BOOKS = ["산림품셈", "건설품셈"];
const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"];
const emptyTable = (): WTable => ({
key: "",
이름: "",
기준: "",
출처: "",
조건: {},
값칸: {},
줄: [],
주: [],
});
/** 단계 안내 — 끝나면 LbLogic · 취소(닫기 · Esc)는 null */
export function openWizard(): Promise<LbLogic | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: LbLogic | null): void => {
if (!settled) resolve(r);
settled = true;
};
const a: WAnswers = {
book: BOOKS[0],
section: "",
table: emptyTable(),
roles: {},
fixed: {},
options: {},
resultUnit: "원/㎡",
mods: [],
links: [],
extras: [],
name: "",
kind: "금액",
valueCol: "",
};
const ctx: WCtx = { a, sectionTables: [], changed: () => runPreview() };
let step = 1;
let sectionTitle = "";
let trial: Record<string, unknown> = {};
let linked = false;
const main = el("div", { className: "m01-lbw__main" });
const side = el("aside", { className: "m01-lbw__side" });
const nav = el("div", { className: "m01-lbw__nav" });
const ask = el("h4", { className: "m01-lbw__ask" });
const sample = el("div", { className: "m01-lbw__sample" });
const result = el("p", { className: "m01-lbw__result", attrs: { "aria-live": "polite" } });
side.append(
el("h5", { text: t("Sample") }),
el("p", { className: "m01-lbw__muted", text: t("SampleHint") }),
sample,
result,
);
/* ── 예시값 ── */
const previewRow = () => {
const money = step >= 5 && a.kind === "금액" && a.links.some((l) => l.kind !== "없음");
const col = a.valueCol || colsOf(a, "결과")[0];
const row = buildRow({ ...a, kind: money ? "금액" : "값", valueCol: col });
return { row, money, col };
};
let paintedFor = "";
const runPreview = (): void => {
if (step < 2 || !colsOf(a, "결과").length) {
sample.replaceChildren();
result.textContent = t("NotYet");
return;
}
const { row, money, col } = previewRow();
const defaults = trialOf(a, row);
trial = Object.fromEntries(
row.입력.map((i) => [i.이름, i.이름 in trial ? trial[i.이름] : defaults[i.이름]]),
);
const sig = JSON.stringify(row.입력);
if (sig !== paintedFor) {
paintedFor = sig;
sample.replaceChildren(
...row.입력.map((i) => {
const label = el("span", { className: "m01-lbw__muted", text: i.이름 });
if (i.고르기?.length) {
const f = createSelectField({
options: i.고르기.map((v) => ({ value: String(v), text: String(v) })),
value: String(trial[i.이름]),
compact: true,
onChange: (v) => (
(trial[i.이름] = i.고르기!.find((x) => String(x) === v)),
runPreview()
),
});
return el("label", { className: "m01-lbw__line", children: [label, f.root] });
}
const f = createInputField({
type: "text",
value: String(trial[i.이름] ?? ""),
compact: true,
});
f.input.addEventListener("input", () => {
const n = Number(f.input.value);
trial[i.이름] = f.input.value.trim() !== "" && Number.isFinite(n) ? n : f.input.value;
runPreview();
});
return el("label", { className: "m01-lbw__line", children: [label, f.root] });
}),
);
}
result.textContent = t("Waiting");
calcLater(row, trial, (ans: CalcAnswer | null, plain: string | null) => {
if (!ans?.ok) return void (result.textContent = plain ?? t("Fail"));
const per = a.resultUnit.replace(/^원\//, "");
const v = money ? (ans.sums?.["계"] ?? Number(ans.result ?? 0)) : Number(ans.result);
result.textContent = money
? t("AmountPer", { p: per, m: Math.round(v).toLocaleString("ko-KR") })
: t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" });
});
};
/* ── 1단계 절 · 표 ── */
const stepSection = (): (() => string | null) => {
const bookBox = createSelectField({
label: t("Book"),
options: BOOKS.map((b) => ({ value: b, text: b })),
value: a.book,
compact: true,
onChange: (v) => ((a.book = v), load()),
});
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m01-lbw__list" });
const tables = el("div", { className: "m01-lbw__tables" });
const preview = el("div", { className: "m01-lbw__preview" });
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book: a.book,
q: q.input.value.trim(),
limit: "60",
});
if (mine !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: `m01-lbw__row${a.section === s.section && sectionTitle === s.title ? " is-on" : ""}`,
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m01-lbw__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables, l: s.logics })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m01-lbw__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mine === seq) showToast(plainError(e), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
a.section = s.section;
sectionTitle = s.title;
a.name = a.name || s.title;
try {
const heads: TableHead[] = [];
for (const group of ["소요량", "계수"])
heads.push(
...(
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: a.book,
size: "100",
})
).tables,
);
ctx.sectionTables = [];
tables.replaceChildren(
el("h5", { text: t("Tables") }),
...heads.map((h) => {
const b = el("button", {
className: `m01-lbw__row${a.table.key === h.키 ? " is-on" : ""}`,
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m01-lbw__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => void pickTable(h, heads));
return b;
}),
);
if (heads.length === 1) await pickTable(heads[0], heads);
main.dispatchEvent(new Event("m01lbw"));
} catch (e) {
showToast(plainError(e), "error");
}
};
const pickTable = async (h: TableHead, heads: TableHead[]): Promise<void> => {
const [full, opt] = await Promise.all([
get<{ table: WTable & { 키: string } }>("/table", { file: h.file, key: h.키 }),
get<{ 값: Record<string, (string | number)[]> }>("/table/options", {
file: h.file,
key: h.키,
}),
]);
a.table = toTable(full.table, h.file);
a.roles = Object.fromEntries([
...Object.keys(a.table.조건).map((c) => [c, "조건" as Role]),
...Object.keys(a.table.값칸).map((c) => [c, "결과" as Role]),
]);
a.options = opt.값 ?? {};
a.fixed = {};
a.links = [];
a.mods = [];
a.resultUnit = unitOfBase(a.table.기준);
linked = false;
ctx.sectionTables = await Promise.all(
heads
.filter((x) => x.키 !== h.키)
.map(async (x) =>
toTable(
(
await get<{ table: WTable & { 키: string } }>("/table", {
file: x.file,
key: x.키,
})
).table,
x.file,
),
),
);
tables
.querySelectorAll(".m01-lbw__row")
.forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키));
preview.replaceChildren(tablePreview(a.table, LBL));
renderNav();
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
main.replaceChildren(bookBox.root, q.root, list, tables, preview);
void load();
if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL));
return () => (a.table.key ? null : t("PickTable"));
};
/* ── 2단계 칸 역할 ── */
const stepRoles = (): (() => string | null) => {
const box = el("div", { className: "m01-lbw__roles" });
const draw = (): void => {
const cols = [...Object.keys(a.table.조건), ...Object.keys(a.table.값칸)];
const chips = cols.map((c) => {
const isCond = c in a.table.조건;
const role = a.roles[c];
const b = el("button", {
className: `m01-lbw__role is-${role}`,
attrs: { type: "button", title: t("RoleHint") },
children: [
el("strong", { text: c }),
el("span", {
text:
role === "조건"
? t("Cond")
: role === "고정"
? t("Fixed")
: role === "결과"
? t("Result")
: t("Unused"),
}),
],
});
b.addEventListener("click", () => {
a.roles[c] = isCond
? role === "조건"
? "고정"
: "조건"
: role === "결과"
? "쓰지 않음"
: "결과";
if (a.roles[c] === "고정" && a.fixed[c] === undefined)
a.fixed[c] = a.options[c]?.[0] ?? "";
ctx.changed();
draw();
});
const extra: HTMLElement[] = [];
if (role === "조건")
extra.push(
el("span", {
className: "m01-lbw__muted",
text:
a.options[c]?.length && a.table.조건[c] !== "범위"
? `${t("Choices")}: ${a.options[c].join(" · ")}`
: t("Free"),
}),
);
if (role === "고정") {
const list = a.options[c] ?? [];
if (list.length)
extra.push(
createSelectField({
options: list.map((v) => ({ value: String(v), text: String(v) })),
value: String(a.fixed[c]),
compact: true,
ariaLabel: t("FixedVal"),
onChange: (v) => (
(a.fixed[c] = list.find((x) => String(x) === v) ?? v),
ctx.changed()
),
}).root,
);
else {
const f = createInputField({
type: "text",
value: String(a.fixed[c] ?? ""),
compact: true,
});
f.input.addEventListener(
"input",
() => (
(a.fixed[c] =
Number.isFinite(Number(f.input.value)) && f.input.value.trim() !== ""
? Number(f.input.value)
: f.input.value),
ctx.changed()
),
);
extra.push(f.root);
}
}
return el("div", { className: "m01-lbw__rolebox", children: [b, ...extra] });
});
box.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("RoleHint") }),
...chips,
tablePreview(a.table, LBL, a.roles),
);
renderNav();
};
main.replaceChildren(box);
draw();
return () => (colsOf(a, "결과").length ? null : t("NeedResult"));
};
/* ── 3단계 결과 단위 ── */
const stepUnit = (): (() => string | null) => {
const per = a.resultUnit.replace(/^원\//, "");
const opts = [...new Set([per, ...UNITS])];
const box = createSelectField({
label: t("UnitPick"),
options: opts.map((u) => ({ value: u, text: `원/${u}` })),
value: per,
onChange: (v) => ((a.resultUnit = `원/${v}`), ctx.changed()),
});
main.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("BaseUnit", { b: a.table.기준 || "—" }) }),
box.root,
);
return () => null;
};
/* ── 6단계 이름 · 결과 종류 ── */
const stepName = (): (() => string | null) => {
const name = createInputField({
type: "text",
label: t("Name"),
value: a.name,
placeholder: "예: 돌쌓기 메쌓기",
});
name.input.addEventListener("input", () => ((a.name = name.input.value), renderNav()));
const per = a.resultUnit.replace(/^원\//, "");
const kinds = el("div", { className: "m01-lbw__chips" });
const valueBox = el("div", { className: "m01-lbw__stack" });
const drawKind = (): void => {
for (const [k, label] of [
["금액", t("KindMoney", { p: per })],
["값", t("KindValue")],
] as const) {
const b = el("button", {
className: `m01-lbw__chip${a.kind === k ? " is-on" : ""}`,
attrs: { type: "button" },
text: label,
});
b.addEventListener(
"click",
() => (
(a.kind = k),
(a.valueCol = a.valueCol || colsOf(a, "결과")[0]),
a.kind === "값" &&
a.resultUnit.startsWith("원/") &&
(a.resultUnit = a.table.값칸[a.valueCol] || ""),
drawKind(),
ctx.changed(),
renderNav()
),
);
kinds.append(b);
}
};
const paint = (): void => {
kinds.replaceChildren();
drawKind();
if (a.kind !== "값") return valueBox.replaceChildren();
const col = createSelectField({
label: t("ValueCol"),
options: colsOf(a, "결과").map((c) => ({ value: c, text: c })),
value: a.valueCol,
onChange: (v) => (
(a.valueCol = v),
(a.resultUnit = a.table.값칸[v] || a.resultUnit),
ctx.changed()
),
});
const unit = createInputField({
type: "text",
label: t("ValueUnit"),
value: a.resultUnit,
placeholder: "예: ㎥/시간",
});
unit.input.addEventListener(
"input",
() => ((a.resultUnit = unit.input.value.trim()), ctx.changed()),
);
valueBox.replaceChildren(col.root, unit.root);
};
kinds.addEventListener("click", () => queueMicrotask(paint));
paint();
main.replaceChildren(name.root, kinds, valueBox);
return () => (a.name.trim() ? null : t("NeedName"));
};
/* ── 틀 ── */
let valid: () => string | null = () => null;
const draw = (): void => {
ask.textContent = t(`Q${step}` as TKey);
const body = el("div");
main.replaceChildren(body);
if (step === 1) valid = stepSection();
else if (step === 2) valid = stepRoles();
else if (step === 3) valid = stepUnit();
else if (step === 4) {
stepCalc(ctx, main);
valid = () => null;
} else if (step === 5) {
if (linked) stepLink(ctx, main);
else {
linked = true;
main.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") }));
void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main));
}
valid = () => null;
} else valid = stepName();
side.hidden = step === 1;
renderNav();
runPreview();
};
const renderNav = (): void => {
const why = valid();
const back = createButton({
label: t("Back"),
variant: "ghost",
disabled: step === 1,
onClick: () => ((step -= 1), draw()),
});
const next = createButton({
label: step === 6 ? t("Make") : t("Next"),
disabled: !!why,
onClick: () => (step === 6 ? make() : ((step += 1), draw())),
});
nav.replaceChildren(
el("span", {
className: "m01-lbw__muted",
text: `${t("StepOf", { n: step })}${why ? ` · ${why}` : ""}`,
}),
back,
next,
);
};
const make = (): void => {
try {
const row = buildRow({ ...a, valueCol: a.valueCol || colsOf(a, "결과")[0] });
const lb = readLogic(row, { key: null, version: null, isBase: false });
finish(lb);
closeModal();
} catch (e) {
showToast(e instanceof Error ? e.message : t("Fail"), "error");
}
};
main.addEventListener("m01lbw", renderNav);
let closeModal = (): void => undefined;
const modal = openModal({
title: t("Title"),
closeLabel: t("Close"),
dialogClass: "m01-lbw",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
closeModal = close;
body.append(ask, el("div", { className: "m01-lbw__cols", children: [main, side] }), nav);
body.addEventListener(
"keydown",
(ev) => ev.altKey && ev.key === "ArrowLeft" && step > 1 && ((step -= 1), draw()),
);
},
});
void modal;
draw();
});
}
@@ -0,0 +1,270 @@
/* =============================================================================
* M01_LB_Wizard_Build.ts
* 단계 안내가 쌓은 답 → 로직 한 줄(LogicRow) 짜기 — DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림)
* 화면 = `M01_LB_Wizard.ts` · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 글은 마스터 로직과 같은 꼴
* ========================================================================== */
import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
type Cell = string | number | [number | null, number | null] | null | undefined;
/** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 */
export interface WTable {
key: string;
/** 표가 든 파일 */
file?: string;
이름: string;
기준: string;
출처: string;
조건: Record<string, string>;
값칸: Record<string, string>;
범위규칙?: string | null;
줄: Record<string, Cell>[];
주: string[];
}
export type Role = "조건" | "고정" | "결과" | "쓰지 않음";
/** 표 조회 율 — 숫자 · 입력값 · 조건 목록(입력값이 한계를 넘을 때만 다른 표에서 찾음) */
export type Rate =
| { t: "num"; v: number }
| { t: "input"; name: string }
| { t: "cond"; input: string; op: "<=" | "<"; limit: number; table: WTable; col: string };
export type Slot = { t: "num"; v: number } | { t: "input"; name: string };
/** 문장 틀 — 할증 · 나누기 · 최소/최대 · 올림 */
export type Mod =
| { k: "할증"; rate: Rate; src?: string }
| { k: "나누기"; by: Slot; src?: string }
| { k: "최소"; v: number; src?: string }
| { k: "최대"; v: number; src?: string }
| { k: "올림"; fn: "올림" | "버림" | "반올림"; places: number; src?: string };
export type LinkKind = "노무" | "재료" | "장비" | "일위대가" | "없음";
/** 결과 칸 하나의 항목 연결 */
export interface Link {
col: string;
kind: LinkKind;
name: string;
spec: string;
unit: string;
element: unknown;
needInputs?: LogicInput[];
}
/** 항목 연결 덧줄 — 비율(바탕의 % · 공구손료 등) · 기타(고정 금액) */
export type Extra =
| {
k: "비율";
name: string;
base: "노무비" | "재료비" | "경비" | "사람줄합";
rate: number;
cost: "노무비" | "재료비" | "경비";
}
| { k: "기타"; name: string; amount: number; cost: "노무비" | "재료비" | "경비" };
export interface WAnswers {
book: string;
section: string;
table: WTable;
roles: Record<string, Role>;
/** 고정 칸 값 */
fixed: Record<string, string | number>;
/** 조건 칸 선택 목록(표 줄에서 저절로) */
options: Record<string, (string | number)[]>;
/** 결과단위 「원/㎡」 */
resultUnit: string;
mods: Mod[];
links: Link[];
extras: Extra[];
name: string;
/** 금액 = 1㎡ 당 금액 · 값 = 다른 로직이 쓸 값(결과 식) */
kind: "금액" | "값";
valueCol: string;
}
/** 단계마다 같이 보는 것 — 화면이 바꾸면 changed() 로 알림(예시값 다시 셈) */
export interface WCtx {
a: WAnswers;
/** 고른 절의 다른 표들(조건 목록 율 찾기용) */
sectionTables: WTable[];
changed(): void;
}
const isWord = (t: string): boolean => /^[\p{L}\p{N}_]+$/u.test(t);
const word = (t: string): string => (isWord(t) ? t : `'${t}'`);
const num = (n: number): string => String(Number(n.toPrecision(12)));
const lit = (v: string | number): string => (typeof v === "number" ? num(v) : `'${v}'`);
/** 역할별 칸 이름 */
export const colsOf = (a: Pick<WAnswers, "roles">, role: Role): string[] =>
Object.keys(a.roles).filter((c) => a.roles[c] === role);
/** 기준 「1㎡」 「100㎡ 당」 「m당」 → 결과단위 「원/㎡」 「원/100㎡」 「원/m」 */
export function unitOfBase(base: string): string {
const per = base
.replace(/\s*당\s*$/, "")
.replace(/^1(?=\D)/, "")
.trim();
return `원/${per || "㎡"}`;
}
/** 표 조회 글 — 조건 칸은 같은 이름 입력값 · 고정 칸은 값 */
export function findText(a: WAnswers, col: string): string {
const args = [
...colsOf(a, "조건").map((c) => `${word(c)}=${word(c)}`),
...colsOf(a, "고정").map((c) => `${word(c)}=${lit(a.fixed[c] ?? "")}`),
];
return `찾기(${[a.table.key, ...args].join(", ")}).${word(col)}`;
}
/** 율 · 나누는 수 — 식 글 · 새로 필요한 중간 값 */
function rateText(
rate: Rate,
mids: { 이름: string; 식: string; 출처?: string }[],
src?: string,
): string {
if (rate.t === "num") return num(rate.v);
if (rate.t === "input") return word(rate.name);
const t = rate.table;
let name = rate.col;
while (mids.some((m) => m.이름 === name) || name === rate.input) name += "값";
const found = `찾기(${t.key}, ${word(rate.input)}=${word(rate.input)}).${word(rate.col)}`;
mids.push({
이름: name,
식: `만약(${word(rate.input)} ${rate.op} ${num(rate.limit)}, 0, ${found})`,
...(src ? { 출처: src } : {}),
});
return name;
}
/** 문장 틀을 차례로 씌운 식 글 — 중간 값은 mids 에 쌓음 */
export function valueText(
a: WAnswers,
col: string,
mids: { 이름: string; 식: string; 출처?: string }[],
rates: Map<Mod, string>,
): string {
let x = findText(a, col);
let atom = true;
const wrap = (): string => (atom ? x : `(${x})`);
for (const m of a.mods) {
if (m.k === "할증") {
if (!rates.has(m)) rates.set(m, rateText(m.rate, mids, m.src));
x = `${wrap()} * (1 + ${rates.get(m)} / 100)`;
atom = false;
} else if (m.k === "나누기") {
x = `${wrap()} / ${m.by.t === "num" ? num(m.by.v) : word(m.by.name)}`;
atom = false;
} else if (m.k === "최소" || m.k === "최대") {
x = `${m.k}(${x}, ${num(m.v)})`;
atom = true;
} else {
x = `${m.fn}(${x}, ${num(m.places)})`;
atom = true;
}
}
return x;
}
/** 입력값 모으기 — 표 조건 칸 · 율 · 나누는 수 · 장비 · 일위대가가 받는 값 */
function inputsOf(a: WAnswers): LogicInput[] {
const out: LogicInput[] = [];
const add = (i: LogicInput): void => {
if (!out.some((x) => x.이름 === i.이름)) out.push(i);
};
for (const c of colsOf(a, "조건")) {
const list = a.options[c];
add(list?.length && a.table.조건[c] !== "범위" ? { 이름: c, 고르기: list } : { 이름: c });
}
for (const m of a.mods) {
if (m.k === "할증" && m.rate.t === "input") add({ 이름: m.rate.name, 단위: "%" });
if (m.k === "할증" && m.rate.t === "cond") add({ 이름: m.rate.input });
if (m.k === "나누기" && m.by.t === "input") add({ 이름: m.by.name });
}
if (a.kind === "금액")
for (const l of a.links)
if (l.kind !== "없음" && a.roles[l.col] === "결과")
for (const n of l.needInputs ?? []) add(n);
return out;
}
const COST: Record<string, string> = { 노무: "노무비", 재료: "재료비" };
/** 사람 줄 노임 합 글 — 「(수량 * 노임 키 + …)」 · 사람 줄이 없으면 0 */
const personSum = (ho: HoLine[]): string => {
const terms = ho
.filter((h) => h.종류 === "인력")
.map(
(h) => `${/^[\p{L}\p{N}_.]+$/u.test(h.수량) ? h.수량 : `(${h.수량})`} * ${String(h.요소)}`,
);
return `(${terms.length ? terms.join(" + ") : "0"})`;
};
/** 답 → 로직 줄 */
export function buildRow(a: WAnswers): LogicRow {
const mids: { 이름: string; 식: string; 출처?: string }[] = [];
const rates = new Map<Mod, string>();
const base: LogicRow = {
키: "",
원문번호: a.section,
이름: a.name,
결과단위: a.resultUnit,
출처: `${a.book} ${a.section}`,
소유: "현장",
입력: [],
중간: [],
};
if (a.kind === "값") {
const col = a.valueCol || colsOf(a, "결과")[0];
const text = col ? valueText(a, col, mids, rates) : "0";
return { ...base, 입력: inputsOf(a), 중간: mids, 결과: { 식: text } };
}
const 호표: HoLine[] = a.links
.filter((l) => l.kind !== "없음" && a.roles[l.col] === "결과")
.map((l) => ({
종류: l.kind === "노무" ? "인력" : l.kind === "재료" ? "재료" : "로직",
요소: l.element as string,
이름: l.name,
...(l.spec ? { 규격: l.spec } : {}),
단위: l.unit,
수량: valueText(a, l.col, mids, rates),
...(COST[l.kind] ? { 비목: COST[l.kind] } : {}),
}));
const 덧줄 = a.extras.map((e) =>
e.k === "비율"
? {
이름: e.name,
식: `${e.base === "사람줄합" ? personSum(호표) : e.base} * ${num(e.rate)} / 100`,
비목: e.cost,
}
: { 이름: e.name, 식: num(e.amount), 비목: e.cost },
);
return { ...base, 입력: inputsOf(a), 중간: mids, 호표, 덧줄, 끝수: null };
}
/** 예시값 — 첫 표 줄에 걸리도록(선택 칸은 첫 줄 값 · 범위 칸은 그 안 수) · 새 입력값은 율 25 · 나누는 수 1 */
export function trialOf(a: WAnswers, row: LogicRow): Record<string, unknown> {
const first = a.table.줄[0] ?? {};
const out: Record<string, unknown> = {};
for (const i of row.입력) {
const v = first[i.이름];
if (Array.isArray(v)) out[i.이름] = v[0] ?? v[1] ?? 1;
else if (v !== undefined && v !== null) out[i.이름] = v;
else if (i.고르기?.length) out[i.이름] = i.고르기[0];
else {
const cond = a.mods.find(
(m) => m.k === "할증" && m.rate.t === "cond" && m.rate.input === i.이름,
);
out[i.이름] =
cond && cond.k === "할증" && cond.rate.t === "cond"
? cond.rate.limit + 1
: i.단위 === "%"
? 25
: 1;
}
}
return out;
}
@@ -0,0 +1,558 @@
/* =============================================================================
* M01_LB_Wizard_Calc.ts
* 단계 안내 4단계(계산 — 주 → 문장 틀) · 5단계(항목 연결) 화면 — `M01_LB_Wizard.ts` 가 부름
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchPick } from "../M01_MasterData_Api_Fetch";
import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick";
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
import {
colsOf,
type Extra,
type Link,
type LinkKind,
type Mod,
type WCtx,
} from "./M01_LB_Wizard_Build";
const TEXT = {
NoteHint: [
"주를 누르면 그 주에 맞는 문장 틀이 뜸 — 안 눌러도 아래에서 바로 고름",
"Press a note to see matching sentences — or pick one below",
],
NoNote: ["이 표에는 주가 없음", "This table has no notes"],
Frames: ["문장 틀", "Sentence"],
Surcharge: ["할증(%)", "Surcharge (%)"],
Divide: ["나누기", "Divide"],
Min: ["작은 값", "Smaller of"],
Max: ["큰 값", "Larger of"],
Round: ["올림 · 버림", "Round"],
RateType: ["율", "Rate"],
RNum: ["숫자", "Number"],
RInput: ["입력값으로 받음", "Ask as an input"],
RCond: ["조건 목록(표 조회)", "Condition list (table)"],
RName: ["입력값 이름", "Input name"],
Value: ["값", "Value"],
Table: ["찾을 표", "Table"],
ResultCol: ["결과 칸", "Result column"],
Limit: ["이하이면 0 · 넘으면 표에서 찾음", "0 up to the limit · above it use the table"],
DivBy: ["나눌 값", "Divide by"],
DNum: ["숫자", "Number"],
DInput: ["입력값으로 받음", "Ask as an input"],
Places: ["소수 자리", "Decimal places"],
Add: ["넣기", "Add"],
Remove: ["삭제", "Remove"],
Added: ["넣은 문장", "Added"],
None: ["아직 없음", "None yet"],
DSurchargeNum: ["{p} % 를 더함", "add {p} %"],
DSurchargeIn: ["「{n}」 % 를 더함(입력값)", "add “{n}” % (input)"],
DSurchargeCond: [
"「{i}」 가 {l} {o}이면 0 · 아니면 「{t}」 의 {c} % 를 더함",
"if “{i}” is {l} {o} add 0, else “{t}” {c} %",
],
OpLe: ["이하", "or less"],
OpLt: ["미만", "under"],
DDivNum: ["{n} 로 나눔", "divide by {n}"],
DDivIn: ["「{n}」 로 나눔(입력값)", "divide by “{n}” (input)"],
DMin: ["{n} 보다 작은 값", "smaller of value and {n}"],
DMax: ["{n} 보다 큰 값", "larger of value and {n}"],
DRound: ["{f} — 소수 {n} 자리", "{f} to {n} places"],
Src: ["출처 {s}", "Source {s}"],
Pick5: ["어느 항목으로 보낼까요?", "Which item should each result go to?"],
Labor: ["노무", "Labor"],
Material: ["재료", "Material"],
Machine: ["장비", "Machine"],
Logic: ["일위대가", "Another job"],
Skip: ["연결 안 함", "Skip"],
Auto: ["이름이 같아 저절로 연결", "Matched by name"],
Choose: ["고르기", "Choose"],
Change: ["변경", "Change"],
NotLinked: ["아직 연결 안 됨", "Not linked yet"],
ExtraHead: ["비율 · 기타 항목", "Rate · other items"],
AddRate: ["+ 비율", "+ Rate"],
AddOther: ["+ 기타", "+ Other"],
ItemName: ["이름", "Name"],
Base: ["바탕", "Of"],
RatePct: ["비율 %", "Rate %"],
Amount: ["금액(원)", "Amount (won)"],
Where: ["넣을 자리", "Goes to"],
SumLabor: ["사람 줄 노무비 합", "Labor sum"],
Labor$: ["노무비", "Labor cost"],
Mat$: ["재료비", "Material cost"],
Exp$: ["경비", "Expense"],
RateLine: ["{b} 의 {r} % — {n}", "{r} % of {b} — {n}"],
OtherLine: ["{n} — {a} 원", "{n} — {a} won"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const numOf = (s: string): number | null =>
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
const sel = (
options: [string, string][],
value: string,
onChange?: (v: string) => void,
): ReturnType<typeof createSelectField> =>
createSelectField({
options: options.map(([value, text]) => ({ value, text })),
value,
compact: true,
onChange,
});
const row = (label: string, ...kids: HTMLElement[]): HTMLElement =>
el("label", {
className: "m01-lbw__line",
children: [el("span", { className: "m01-lbw__muted", text: label }), ...kids],
});
/* ── 4단계 계산 ─────────────────────────────────────────────────────────── */
const describe = (m: Mod): string => {
const src = m.src ? ` · ${t("Src", { s: m.src })}` : "";
if (m.k === "할증") {
const r = m.rate;
const text =
r.t === "num"
? t("DSurchargeNum", { p: r.v })
: r.t === "input"
? t("DSurchargeIn", { n: r.name })
: t("DSurchargeCond", {
i: r.input,
l: r.limit,
o: t(r.op === "<=" ? "OpLe" : "OpLt"),
t: r.table.이름,
c: r.col,
});
return `${t("Surcharge")} — ${text}${src}`;
}
if (m.k === "나누기")
return `${t("Divide")} — ${m.by.t === "num" ? t("DDivNum", { n: m.by.v }) : t("DDivIn", { n: m.by.name })}${src}`;
if (m.k === "최소" || m.k === "최대")
return `${m.k === "최소" ? t("Min") : t("Max")} — ${t(m.k === "최소" ? "DMin" : "DMax", { n: m.v })}${src}`;
return `${t("Round")} — ${t("DRound", { f: m.fn, n: m.places })}${src}`;
};
/** 주 글에서 맞는 문장 틀 추리기 */
const guess = (note: string): string =>
/할증|증가|가산|초과/.test(note)
? "할증"
: /나누|시공량|당\s*작업/.test(note)
? "나누기"
: /이상|이하|최대|최소/.test(note)
? "최대"
: /올림|절상|반올림/.test(note)
? "올림"
: "";
export function stepCalc(ctx: WCtx, host: HTMLElement): void {
const { a } = ctx;
let frame = "";
let src = "";
const draw = (): void => {
const notes = a.table.주;
const noteBox = el("div", { className: "m01-lbw__notes" });
if (!notes.length) noteBox.append(el("p", { className: "m01-lbw__muted", text: t("NoNote") }));
notes.forEach((n, i) => {
const b = el("button", {
className: `m01-lbw__note${src === `주${i}` ? " is-on" : ""}`,
attrs: { type: "button" },
text: n,
});
b.addEventListener("click", () => {
src = `주${i}`;
frame = guess(n) || frame;
draw();
});
noteBox.append(b);
});
const noteSrc = (): string | undefined => {
const i = Number(src.slice(1));
const mark = src && notes[i] ? (notes[i].match(/^[①-⑳]/)?.[0] ?? `${i + 1}`) : "";
return mark ? `${a.table.출처} 주${mark}` : undefined;
};
const frames: [string, TKey][] = [
["할증", "Surcharge"],
["나누기", "Divide"],
["최소", "Min"],
["최대", "Max"],
["올림", "Round"],
];
const tabs = el("div", {
className: "m01-lbw__chips",
children: frames.map(([k, label]) => {
const b = el("button", {
className: `m01-lbw__chip${frame === k ? " is-on" : ""}`,
attrs: { type: "button" },
text: t(label),
});
b.addEventListener("click", () => ((frame = k), draw()));
return b;
}),
});
const added = el("div", { className: "m01-lbw__added" });
a.mods.forEach((m, i) => {
const rm = createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => (a.mods.splice(i, 1), ctx.changed(), draw()),
});
added.append(
el("div", {
className: "m01-lbw__item",
children: [el("span", { text: describe(m) }), rm],
}),
);
});
if (!a.mods.length) added.append(el("p", { className: "m01-lbw__muted", text: t("None") }));
host.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("NoteHint") }),
noteBox,
el("h5", { text: t("Frames") }),
tabs,
frame ? form(frame, noteSrc()) : el("span"),
el("h5", { text: t("Added") }),
added,
);
};
const form = (k: string, from: string | undefined): HTMLElement => {
const box = el("div", { className: "m01-lbw__form" });
const push = (m: Mod): void => (
a.mods.push(from ? { ...m, src: from } : m),
(frame = ""),
(src = ""),
ctx.changed(),
draw()
);
const addBtn = (go: () => void): HTMLElement => createButton({ label: t("Add"), onClick: go });
if (k === "할증") {
const others = ctx.sectionTables.filter(
(x) =>
x.key !== a.table.key && Object.keys(x.조건).length === 1 && Object.keys(x.값칸).length,
);
const type = sel(
[
["num", t("RNum")],
["input", t("RInput")],
...(others.length ? ([["cond", t("RCond")]] as [string, string][]) : []),
],
"num",
() => paint(),
);
const body = el("div", { className: "m01-lbw__stack" });
const paint = (): void => {
const num = createInputField({ type: "text", placeholder: "예: 30", compact: true });
const nm = createInputField({ type: "text", value: "할증률", compact: true });
const tb = sel(
others.map((x) => [x.key, x.이름]),
others[0]?.key ?? "",
() => paint(),
);
const tbl = others.find((x) => x.key === tb.select.value) ?? others[0];
const col = sel(
Object.keys(tbl?.값칸 ?? {}).map((c) => [c, c]),
"",
);
const lim = createInputField({ type: "text", placeholder: "예: 3", compact: true });
const op = sel(
[
["<=", "≤ (이하)"],
["<", "< (미만)"],
],
"<=",
);
body.replaceChildren(
...(type.select.value === "num"
? [row(t("Value"), num.root)]
: type.select.value === "input"
? [row(t("RName"), nm.root)]
: [
row(t("Table"), tb.root),
row(t("ResultCol"), col.root),
row(t("Limit"), op.root, lim.root),
]),
addBtn(() => {
const ty = type.select.value;
const n = numOf(num.input.value);
const l = numOf(lim.input.value);
if (ty === "num" && n !== null) push({ k: "할증", rate: { t: "num", v: n } });
else if (ty === "input" && nm.input.value.trim())
push({ k: "할증", rate: { t: "input", name: nm.input.value.trim() } });
else if (ty === "cond" && tbl && l !== null)
push({
k: "할증",
rate: {
t: "cond",
input: Object.keys(tbl.조건)[0],
op: op.select.value as "<=" | "<",
limit: l,
table: tbl,
col: col.select.value,
},
});
}),
);
};
paint();
box.append(row(t("RateType"), type.root), body);
} else if (k === "나누기") {
const ty = sel(
[
["num", t("DNum")],
["input", t("DInput")],
],
"num",
() => paint(),
);
const f = createInputField({ type: "text", placeholder: "예: 8", compact: true });
const body = el("div", { className: "m01-lbw__stack" });
const paint = (): void => {
f.input.value = ty.select.value === "input" ? "시공량" : "";
body.replaceChildren(row(t("DivBy"), f.root));
};
paint();
box.append(
row(t("DivBy"), ty.root),
body,
addBtn(() => {
const n = numOf(f.input.value);
if (ty.select.value === "num" && n !== null && n !== 0)
push({ k: "나누기", by: { t: "num", v: n } });
else if (ty.select.value === "input" && f.input.value.trim())
push({ k: "나누기", by: { t: "input", name: f.input.value.trim() } });
}),
);
} else if (k === "최소" || k === "최대") {
const f = createInputField({ type: "text", placeholder: "예: 100", compact: true });
box.append(
row(t(k === "최소" ? "Min" : "Max"), f.root),
addBtn(() => {
const n = numOf(f.input.value);
if (n !== null) push({ k, v: n });
}),
);
} else {
const fn = sel(
[
["올림", "올림"],
["버림", "버림"],
["반올림", "반올림"],
],
"올림",
);
const p = createInputField({ type: "text", value: "2", compact: true });
box.append(
row(t("Round"), fn.root),
row(t("Places"), p.root),
addBtn(() => {
const n = numOf(p.input.value);
if (n !== null && n >= 0)
push({ k: "올림", fn: fn.select.value as "올림", places: Math.floor(n) });
}),
);
}
return box;
};
draw();
}
/* ── 5단계 항목 연결 ───────────────────────────────────────────────────── */
/** 처음 들어올 때 — 결과 칸 이름이 노임 · 자재 이름과 같으면 저절로 연결 */
export async function autoLinks(ctx: WCtx): Promise<void> {
const { a } = ctx;
for (const col of colsOf(a, "결과")) {
if (a.links.some((l) => l.col === col && l.kind !== "없음")) continue;
const unit = a.table.값칸[col] ?? "";
const link: Link = { col, kind: "없음", name: col, spec: "", unit, element: null };
try {
const job = (await fetchPick("job", col)).items.find((i) => i.이름 === col);
if (job) Object.assign(link, { kind: "노무", unit: unit || "인", element: job.ref });
else {
const mat = (await fetchPick("price", col)).items.find((i) => i.이름 === col);
if (mat)
Object.assign(link, {
kind: "재료",
spec: mat.규격,
unit: unit || mat.단위,
element: {
구분: (mat as { 구분?: string }).구분 ?? "",
검색어: `${mat.이름} ${mat.규격}`.trim(),
},
});
}
} catch {
/* 못 찾으면 연결 안 함으로 둠 */
}
a.links = a.links.filter((l) => l.col !== col);
a.links.push(link);
}
ctx.changed();
}
const KINDS: [LinkKind, TKey, "person" | "material" | "machine" | "logic"][] = [
["노무", "Labor", "person"],
["재료", "Material", "material"],
["장비", "Machine", "machine"],
["일위대가", "Logic", "logic"],
];
export function stepLink(ctx: WCtx, host: HTMLElement): void {
const { a } = ctx;
const draw = (): void => {
const rows = colsOf(a, "결과").map((col) => {
const link = a.links.find((l) => l.col === col) ?? {
col,
kind: "없음" as LinkKind,
name: col,
spec: "",
unit: a.table.값칸[col] ?? "",
element: null,
};
if (!a.links.includes(link)) a.links.push(link);
const pick = async (
kind: LinkKind,
pk: "person" | "material" | "machine" | "logic",
): Promise<void> => {
const r = await openEasyPick(pk, { qty: 1 });
if (!r) return;
Object.assign(link, {
kind,
name: r.name,
spec: r.spec,
unit: a.table.값칸[col] || r.unit,
element: r.element,
needInputs: r.needInputs,
});
ctx.changed();
draw();
};
const btns = KINDS.map(([kind, label, pk]) => {
const b = el("button", {
className: `m01-lbw__chip${link.kind === kind ? " is-on" : ""}`,
attrs: { type: "button" },
text: t(label),
});
b.addEventListener(
"click",
() => void pick(kind, pk).catch((e) => showToast(plainError(e), "error")),
);
return b;
});
const none = el("button", {
className: `m01-lbw__chip${link.kind === "없음" ? " is-on" : ""}`,
attrs: { type: "button" },
text: t("Skip"),
});
none.addEventListener(
"click",
() => (Object.assign(link, { kind: "없음", element: null }), ctx.changed(), draw()),
);
const state = link.kind === "없음" ? t("NotLinked") : `${link.name} ${link.spec}`.trim();
return el("div", {
className: "m01-lbw__link",
children: [
el("strong", { text: `${col} (${a.table.값칸[col] || "—"})` }),
el("div", { className: "m01-lbw__chips", children: [...btns, none] }),
el("span", { className: "m01-lbw__muted", text: state }),
],
});
});
host.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("Pick5") }),
...rows,
extras(ctx, draw),
);
};
draw();
}
/** 비율 · 기타 덧줄 */
function extras(ctx: WCtx, redraw: () => void): HTMLElement {
const { a } = ctx;
const costs: [string, string][] = [
["노무비", t("Labor$")],
["재료비", t("Mat$")],
["경비", t("Exp$")],
];
const bases: [string, string][] = [["사람줄합", t("SumLabor")], ...costs];
const list = a.extras.map((e: Extra, i) => {
const text =
e.k === "비율"
? t("RateLine", {
b: bases.find(([v]) => v === e.base)?.[1] ?? e.base,
r: e.rate,
n: e.name,
})
: t("OtherLine", { n: e.name, a: e.amount });
return el("div", {
className: "m01-lbw__item",
children: [
el("span", { text }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => (a.extras.splice(i, 1), ctx.changed(), redraw()),
}),
],
});
});
const name = createInputField({ type: "text", placeholder: t("ItemName"), compact: true });
const base = sel(bases, "사람줄합");
const val = createInputField({ type: "text", placeholder: t("RatePct"), compact: true });
const where = sel(costs, "재료비");
const add = (k: "비율" | "기타"): void => {
const n = numOf(val.input.value);
const nm = name.input.value.trim();
if (n === null || !nm) return;
const cost = where.select.value as "노무비";
a.extras.push(
k === "비율"
? { k, name: nm, base: base.select.value as "노무비", rate: n, cost }
: { k, name: nm, amount: n, cost },
);
ctx.changed();
redraw();
};
return el("div", {
className: "m01-lbw__extras",
children: [
el("h5", { text: t("ExtraHead") }),
...list,
el("div", {
className: "m01-lbw__form",
children: [
row(t("ItemName"), name.root),
row(t("Base"), base.root),
row(`${t("RatePct")} / ${t("Amount")}`, val.root),
row(t("Where"), where.root),
el("div", {
className: "m01-lbw__chips",
children: [
createButton({ label: t("AddRate"), onClick: () => add("비율") }),
createButton({ label: t("AddOther"), variant: "ghost", onClick: () => add("기타") }),
],
}),
],
}),
],
});
}
@@ -0,0 +1,94 @@
/* =============================================================================
* M01_LB_Wizard_Parts.ts
* 단계 안내 곁부품 — 서버 읽기(절 · 표) · 표 → WTable · 원문 표 미리 보기 · `M01_LB_Wizard.ts` 가 부름
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "@ui/ui_template_elements";
import { ApiError } from "../M01_MasterData_UI_Logic_Api";
import type { Role, WTable } from "./M01_LB_Wizard_Build";
export async function get<T>(path: string, params: Record<string, string>): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
credentials: "include",
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!res.ok) throw new ApiError(res.status, data.detail ?? res.statusText);
return data;
}
export interface SectionItem {
section: string;
title: string;
chapter: string;
tables: number;
logics: number;
}
export interface TableHead {
file: string;
키: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
/** 서버 표 답 → WTable */
export function toTable(raw: WTable & { 키: string }, file: string): WTable {
return {
key: raw.키,
file,
이름: raw.이름,
기준: raw.기준 ?? "",
출처: raw.출처 ?? "",
조건: raw.조건,
값칸: raw.값칸,
범위규칙: raw.범위규칙 ?? null,
줄: raw.줄,
주: raw.주 ?? [],
};
}
const cellText = (v: unknown): string =>
Array.isArray(v)
? v.map((x) => x ?? "").join("~")
: v === null || v === undefined
? ""
: String(v);
/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */
export function tablePreview(
tb: WTable,
labels: { preview: string; notes: string },
roles?: Record<string, Role>,
): HTMLElement {
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
const head = el("tr", {
children: cols.map((c) =>
el("th", { className: `is-${roles?.[c] ?? (c in tb.조건 ? "조건" : "결과")}`, text: c }),
),
});
const rows = tb.줄
.slice(0, 6)
.map((r) => el("tr", { children: cols.map((c) => el("td", { text: cellText(r[c]) })) }));
return el("div", {
className: "m01-lbw__table",
children: [
el("h5", { text: `${labels.preview} — ${tb.이름} (${tb.기준 || "—"})` }),
el("div", {
className: "m01-lbw__scroll",
children: [el("table", { children: [head, ...rows] })],
}),
...(tb.줄.length > 6
? [el("p", { className: "m01-lbw__muted", text: `… ${tb.줄.length} 줄` })]
: []),
...(tb.주.length
? [
el("h5", { text: labels.notes }),
...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })),
]
: []),
],
});
}
@@ -0,0 +1,193 @@
"""로직 만들기 판 단계 안내 — 답 → 로직 줄 짜기(`M01_LB_Wizard_Build.ts`).
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 순수 함수만 부름.
견본 표 · 답은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Wizard_Build.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "unit") return m.unitOfBase(job.base);
if (job.op === "row") return m.buildRow(job.a);
if (job.op === "trial") return m.trialOf(job.a, m.buildRow(job.a));
return null;
});
process.stdout.write(JSON.stringify(out));
"""
TABLE = {
"key": "QF900001",
"이름": "견본 쌓기",
"기준": "1㎡",
"출처": "산림품셈 9-9-9",
"조건": {"뒷길이": "수", "돌": "고르기"},
"값칸": {"석공": "인", "보통인부": "인"},
"줄": [
{"뒷길이": 25, "돌": "깬돌", "석공": 0.15, "보통인부": 0.12},
{"뒷길이": 30, "돌": "깬돌", "석공": 0.26, "보통인부": 0.21},
],
"주": ["③ 높이 3m 초과 시 할증"],
}
RATE_TABLE = {
"key": "QF900002",
"이름": "견본 증가율표",
"기준": "",
"출처": "산림품셈 9-9-9 주③",
"조건": {"높이": "범위"},
"값칸": {"증가율": "%"},
"줄": [{"높이": [3, 4], "증가율": 30}],
"주": [],
}
def answers(**more) -> dict:
base = {
"book": "산림품셈",
"section": "9-9-9",
"table": TABLE,
"roles": {"뒷길이": "조건", "돌": "조건", "석공": "결과", "보통인부": "결과"},
"fixed": {},
"options": {"뒷길이": [25, 30], "돌": ["깬돌"]},
"resultUnit": "원/㎡",
"mods": [],
"links": [
{
"col": "석공",
"kind": "노무",
"name": "석공",
"spec": "",
"unit": "인",
"element": "LB000033",
},
{
"col": "보통인부",
"kind": "노무",
"name": "보통인부",
"spec": "",
"unit": "인",
"element": "LB000002",
},
],
"extras": [],
"name": "견본",
"kind": "금액",
"valueCol": "",
}
base.update(more)
return base
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
def test_결과단위는_표_기준에서():
out = run([{"op": "unit", "base": b} for b in ("1㎡", "100㎡ 당", "㎡당", "m당", "")])
assert out == ["원/㎡", "원/100㎡", "원/㎡", "원/m", "원/㎡"]
def test_기본_두_줄():
row = run([{"op": "row", "a": answers()}])[0]
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌"]
assert row["입력"][0]["고르기"] == [25, 30]
assert row["중간"] == []
assert [h["수량"] for h in row["호표"]] == [
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공",
"찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부",
]
assert row["호표"][0]["종류"] == "인력" and row["호표"][0]["비목"] == "노무비"
assert row["결과단위"] == "원/㎡" and row["출처"] == "산림품셈 9-9-9"
def test_고정칸은_값으로():
a = answers(
roles={"뒷길이": "고정", "돌": "조건", "석공": "결과", "보통인부": "쓰지 않음"},
fixed={"뒷길이": 25},
)
row = run([{"op": "row", "a": a}])[0]
assert [i["이름"] for i in row["입력"]] == ["돌"]
assert [h["수량"] for h in row["호표"]] == ["찾기(QF900001, 돌=돌, 뒷길이=25).석공"]
def test_문장틀_차례대로_씌움():
cond = {
"t": "cond",
"input": "높이",
"op": "<=",
"limit": 3,
"table": RATE_TABLE,
"col": "증가율",
}
a = answers(
mods=[
{"k": "할증", "rate": cond, "src": "산림품셈 9-9-9 주③"},
{"k": "나누기", "by": {"t": "input", "name": "시공량"}},
{"k": "올림", "fn": "반올림", "places": 3},
]
)
row = run([{"op": "row", "a": a}])[0]
assert row["중간"] == [
{
"이름": "증가율",
"식": "만약(높이 <= 3, 0, 찾기(QF900002, 높이=높이).증가율)",
"출처": "산림품셈 9-9-9 주③",
}
]
assert row["호표"][0]["수량"] == (
"반올림((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)) / 시공량, 3)"
)
assert [i["이름"] for i in row["입력"]] == ["뒷길이", "돌", "높이", "시공량"]
def test_비율_덧줄은_사람줄합():
extras = [{"k": "비율", "name": "공구손료", "base": "사람줄합", "rate": 3, "cost": "재료비"}]
row = run([{"op": "row", "a": answers(extras=extras)}])[0]
assert row["덧줄"] == [
{
"이름": "공구손료",
"식": "((찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공) * LB000033 + (찾기(QF900001, 뒷길이=뒷길이, 돌=돌).보통인부) * LB000002) * 3 / 100",
"비목": "재료비",
}
]
def test_값_종류는_결과_식_하나():
a = answers(kind="값", valueCol="석공", resultUnit="인/㎡")
row = run([{"op": "row", "a": a}])[0]
assert row["결과"] == {"식": "찾기(QF900001, 뒷길이=뒷길이, 돌=돌).석공"}
assert "호표" not in row and row["결과단위"] == "인/㎡"
def test_예시값은_첫_줄에_걸림():
out = run([{"op": "trial", "a": answers()}])[0]
assert out == {"뒷길이": 25, "돌": "깬돌"}