Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_4
This commit is contained in:
@@ -20,7 +20,6 @@ import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logi
|
||||
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
calcLater,
|
||||
copyEasy,
|
||||
listEasy,
|
||||
openEasy,
|
||||
@@ -117,7 +116,7 @@ interface Paper {
|
||||
tables: LbTables;
|
||||
}
|
||||
|
||||
/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
|
||||
/** 블록 읽기 — 못 읽는 꼴이면 null(세 칸은 원래 줄로 보임) */
|
||||
const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => {
|
||||
try {
|
||||
return readLogic(row, { key, version, isBase });
|
||||
@@ -125,16 +124,6 @@ const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean):
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const calcWith = (
|
||||
p: Paper,
|
||||
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
|
||||
): void => {
|
||||
try {
|
||||
lbCalcLater(p.row, p.trial, p.tables, onDone);
|
||||
} catch {
|
||||
calcLater(p.row, p.trial, onDone);
|
||||
}
|
||||
};
|
||||
|
||||
/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createLogicBuilderButton(): HTMLButtonElement {
|
||||
@@ -306,7 +295,7 @@ function openLogicBuilder(): void {
|
||||
|
||||
const calc = (): void => {
|
||||
drawResult(true);
|
||||
calcWith(p, (next, plain) => {
|
||||
lbCalcLater(p.row, p.trial, p.tables, (next, plain) => {
|
||||
ans = next;
|
||||
note.textContent = plain ?? "";
|
||||
drawResult(false);
|
||||
|
||||
@@ -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,698 @@
|
||||
/* =============================================================================
|
||||
* M01_LB_Wizard.ts
|
||||
* 로직 만들기 판 — 단계 안내 6단계 · openWizard() → LbLogic | null(취소) · 저장은 Entry 몫
|
||||
* ① 어느 품셈 절 ② 표 칸 역할 ③ 결과 단위 ④ 계산(Calc.ts) ⑤ 항목 연결(Calc.ts) ⑥ 이름 · 결과 종류
|
||||
* 답은 Build.ts 가 로직 한 줄로 짜고 Blocks.readLogic 이 블록으로 읽음 · 답할 때마다 곁에 예시값
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
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 { ApiError, 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 "./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"],
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface SectionItem {
|
||||
section: string;
|
||||
title: string;
|
||||
chapter: string;
|
||||
tables: number;
|
||||
logics: number;
|
||||
}
|
||||
interface TableHead {
|
||||
file: string;
|
||||
키: string;
|
||||
이름: string;
|
||||
기준: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
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: fmt(v) })
|
||||
: 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));
|
||||
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));
|
||||
return () => (a.table.key ? null : t("Tables"));
|
||||
};
|
||||
|
||||
/* ── 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, 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) {
|
||||
stepLink(ctx, main);
|
||||
if (!linked)
|
||||
((linked = true), 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();
|
||||
});
|
||||
}
|
||||
|
||||
/** 서버 표 답 → WTable */
|
||||
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);
|
||||
|
||||
/** 원문 표 앞 몇 줄 + 주 — 칸 머리 색 = 역할 */
|
||||
function tablePreview(tb: WTable, 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: `${t("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: t("Notes") }),
|
||||
...tb.주.map((n) => el("p", { className: "m01-lbw__note-line", text: n })),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/* =============================================================================
|
||||
* 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 });
|
||||
}
|
||||
for (const l of a.links) for (const n of l.needInputs ?? []) add(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
const COST: Record<string, string> = { 노무: "노무비", 재료: "재료비" };
|
||||
|
||||
/** 답 → 로직 줄 */
|
||||
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} * ${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,546 @@
|
||||
/* =============================================================================
|
||||
* 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 } 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}” {o} {l} add 0, else “{t}” {c} %",
|
||||
],
|
||||
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: r.op === "<=" ? "≤" : "<",
|
||||
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)) 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.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) => alert(plainError(e))));
|
||||
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("기타") }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user