feat(M01): 단가산출 로직 단추 정리 · 로직 상세 두 칸

- 「식으로 새로 만들기」 단추 · 화면 길 지움(LogicLab_New 다섯 파일 — 쓰는 곳 없음)
- 「시험판 ▾」 메뉴 · 시험판 단추 둘(CostFlow · DataFlow 입구) · 가져오기 줄 지움
- 「본떠 만들기」 → 「로직 복사/생성」 · 단추 차례 = ← 로직 목록 · 로직 복사/생성 · 로직 지우기 · 고친 것 버리기 · 저장
- 로직 상세 두 칸(LogicLab_Flow) — 왼쪽 지금 내용 · 오른쪽 흐름도 자리 · 끌기 막대 · 너비 비율 기억(localStorage · 25~75%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-01 23:27:29 +09:00
co-authored by Claude Opus 5.5
parent 5ec3c395b2
commit accf8ecc4f
9 changed files with 160 additions and 948 deletions
+24 -103
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab.ts
* 관리자 화면 — 로직 칸. 가운데 단가산출 상세(호표) 고치기 / 오른쪽 시험 계산
* 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 단가산출 상세(호표) · 시험 계산 / 오른쪽 흐름도
* 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import { currentLanguageIndex, t as L } from "@ui/ui_template_locale";
import { t as L } from "@ui/ui_template_locale";
import {
createButton,
el,
@@ -37,12 +37,7 @@ import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail"
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createCostFlowButton } from "../M02_MasterTemplete/CostFlow/M02_CF_Entry";
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createDataFlowButton } from "../M02_MasterTemplete/DataFlow/M02_DF_Entry";
import { buildSplit } from "./M01_MasterData_UI_LogicLab_Flow";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -72,49 +67,6 @@ interface Opened extends Draft {
const cacheKey = "m01_logic_drafts_lab";
const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" });
/** 시험판 묶음 — 「시험판 ▾」 단추 하나 · 누르면 시험판 단추들이 목록으로(바깥 · Esc · 고르면 닫힘) */
function trialMenu(items: HTMLElement[]): HTMLElement {
const list = el("div", { attrs: { role: "menu" }, children: items });
Object.assign(list.style, {
position: "absolute",
top: "calc(100% + 4px)",
left: "0",
zIndex: "20",
display: "none",
flexDirection: "column",
gap: "var(--spacing-4)",
padding: "var(--spacing-8)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
background: "var(--color-surface-raised)",
boxShadow: "var(--shadow-lg)",
whiteSpace: "nowrap",
});
const shut = (): void => {
list.style.display = "none";
button.setAttribute("aria-expanded", "false");
document.removeEventListener("click", outside, true);
};
const outside = (ev: Event): void => {
if (!wrap.contains(ev.target as Node)) shut();
};
const button = createButton({
label: ["시험판 ▾", "Trials ▾"][currentLanguageIndex === 1 ? 1 : 0],
variant: "ghost",
onClick: () => {
if (list.style.display !== "none") return shut();
list.style.display = "flex";
button.setAttribute("aria-expanded", "true");
document.addEventListener("click", outside, true);
},
});
button.setAttribute("aria-haspopup", "menu");
list.addEventListener("click", () => shut()); // 고른 시험판은 제 단추가 엶
const wrap = el("div", { children: [button, list] });
Object.assign(wrap.style, { position: "relative", display: "inline-flex" });
wrap.addEventListener("keydown", (ev) => ev.key === "Escape" && shut());
return wrap;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
@@ -137,13 +89,13 @@ export async function mountM01LogicLab(
): Promise<LogicHandle> {
let drafts = loadDrafts();
let opened: Opened | null = null;
let creating = false; // 「식으로 새로 만들기」 화면이 열려 있음
let calcInputs = "";
let recalc: number | undefined;
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
const split = buildSplit(editor);
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("div", { className: "m01lab__calc" });
const newHost = el("div", { className: "m01lab__new", attrs: { hidden: "" } });
const filterHost = el("div");
const waiting = el("p", { className: "m01-logic__muted", text: tx("Loading") });
let loaded = false; // 첫 목록이 오기 전에는 「0 줄」 빈 표를 보이지 않음
@@ -233,12 +185,11 @@ export async function mountM01LogicLab(
const drawEditor = (): void => {
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
const listing = !opened && !creating;
const listing = !opened;
list.root.hidden = !listing || !loaded;
waiting.hidden = loaded;
editor.hidden = !opened;
split.root.hidden = !opened;
back.hidden = listing;
newHost.hidden = !creating;
if (!opened) return;
if (!opened.row) {
editor.replaceChildren(
@@ -264,7 +215,6 @@ export async function mountM01LogicLab(
const show = (next: Opened | null): void => {
opened = next;
creating = false;
errors.hidden = true;
drawEditor();
drawCalc();
@@ -326,7 +276,7 @@ export async function mountM01LogicLab(
restore: true,
onPick: (sub, detail) => {
list.setPick(sub, detail);
if (opened || creating) show(null);
if (opened) show(null);
},
}),
);
@@ -410,65 +360,36 @@ export async function mountM01LogicLab(
}
};
/** 식으로 새로 만들기 — 저장하면 목록을 다시 받아 그 로직의 상세 화면을 엶 */
const startNew = (): void => {
opened = null;
creating = true;
newHost.replaceChildren(
buildNewLogic({
onSaved: (key) => {
void (async () => {
try {
await reload();
await openKeyOn(key);
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
})();
},
onClose: () => show(null),
}),
);
drawEditor();
drawCalc();
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
variant: "ghost",
onClick: () => void onDiscard(),
});
const copyButton = createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
});
const bar = el("div", {
className: "m01-logic__bar",
children: [
back,
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
trialMenu([
// 단가산출 다이어그램 시험 길 — 폴더째 지우면 이 두 줄도 지움
createCostFlowButton(),
// 단가산출 흐름도 시험 길 — 폴더째 지우면 이 두 줄도 지움
createDataFlowButton(),
]),
copyButton,
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
createButton({
label: tl("Copy_Open"),
variant: "ghost",
onClick: () =>
opened?.origKey
? void openCopyScreen({
key: opened.origKey,
afterSave: async (newKey) => {
await reload();
await open(logicId("자체", newKey), "자체", newKey);
},
})
: showToast(tl("Copy_NeedOpen"), "info"),
}),
],
});
host.replaceChildren(
@@ -477,7 +398,7 @@ export async function mountM01LogicLab(
children: [
el("main", {
className: "m01-logic__main",
children: [bar, errors, waiting, list.root, editor, newHost],
children: [bar, errors, waiting, list.root, split.root],
}),
],
}),
@@ -0,0 +1,40 @@
/* 로직 상세 두 칸 — 왼쪽 지금 내용 · 끌기 막대 · 오른쪽 흐름도 */
.m01lab-flow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: 0;
}
.m01lab-flow[hidden] {
display: none;
}
.m01lab-flow__left,
.m01lab-flow__right {
min-width: 0;
}
.m01lab-flow__bar {
align-self: stretch;
width: 8px;
margin: 0 var(--spacing-4);
border-radius: var(--radius-sm);
background: var(--color-border);
cursor: col-resize;
touch-action: none;
}
.m01lab-flow__bar:hover,
.m01lab-flow__bar:focus-visible {
background: var(--color-primary);
}
.m01lab-flow__wait {
margin: 0;
padding: var(--spacing-16);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
text-align: center;
}
@@ -0,0 +1,95 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Flow.ts
* 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 자리(패널은 LogicLab 이 붙임)
* 너비 비율은 브라우저에 기억(localStorage · 막히면 기본 반반). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 5 · 10
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import "./M01_MasterData_UI_LogicLab_Flow.css";
const TEXT = {
Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"],
Waiting: ["흐름도 자리", "Flow diagram"],
} as const;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const STORE = "m01.logic.flowRatio";
const MIN = 0.25;
const MAX = 0.75;
const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r));
function readRatio(): number {
try {
const r = Number(localStorage.getItem(STORE));
return r ? clamp(r) : 0.5;
} catch {
return 0.5;
}
}
export interface SplitHandle {
root: HTMLElement;
/** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */
right: HTMLElement;
}
/** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */
export function buildSplit(left: HTMLElement): SplitHandle {
const right = el("div", {
className: "m01lab-flow__right",
children: [el("p", { className: "m01lab-flow__wait", text: tf("Waiting") })],
});
const bar = el("div", {
className: "m01lab-flow__bar",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Resize"),
title: tf("Resize"),
tabindex: "0",
},
});
const leftCol = el("div", { className: "m01lab-flow__left", children: [left] });
const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] });
let ratio = readRatio();
const apply = (): void => {
root.style.gridTemplateColumns = `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)`;
};
const keep = (): void => {
try {
localStorage.setItem(STORE, String(ratio));
} catch {
/* 기억 못 해도 지금 화면은 그대로 */
}
};
bar.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const box = root.getBoundingClientRect();
const move = (m: PointerEvent): void => {
ratio = clamp((m.clientX - box.left) / box.width);
apply();
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
keep();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
// 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩
bar.addEventListener("keydown", (ev) => {
const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0;
if (!step) return;
ev.preventDefault();
ratio = clamp(ratio + step);
apply();
keep();
});
apply();
return { root, right };
}
@@ -1,105 +0,0 @@
/* M01 로직 개선 시험 — 식으로 새 로직 만들기 (좁은 화면에서도 한 칸으로 쌓임) */
.m01lab-new {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01lab-new__step {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__text {
width: 100%;
min-height: 140px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg);
color: var(--color-text);
font-family: ui-monospace, Consolas, monospace;
font-size: var(--text-body);
resize: vertical;
}
.m01lab-new__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m01lab-new__vars {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01lab-new__var {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01lab-new__var-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
}
.m01lab-new__var-body {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8);
margin-top: var(--spacing-8);
}
.m01lab-new__wide {
flex: 1 1 220px;
}
.m01lab-new__picked {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-new__src {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-paper);
overflow-x: auto;
}
.m01lab-new__no {
flex: 0 0 auto;
color: var(--color-text-secondary);
}
.m01lab-new__src mark {
background: var(--color-danger);
color: var(--color-bg);
border-radius: 2px;
}
.m01lab-new__bad {
color: var(--color-danger);
}
.m01lab-new__soft {
color: var(--color-text-secondary);
}
.m01lab-new__calc {
min-width: 0;
}
@@ -1,272 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New.ts
* 「단가산출 로직」 — 식으로 새 로직 만들기 (PLAN 3-3 · 계약 `_화면_계약.md` 7장)
* ① 식을 줄로 적음 ② [분석] → 변수 표에서 변수마다 무엇인지·단위·비목을 고름
* ③ 이름·결과 단위 ④ 시험 계산으로 값을 보고 저장(자체 로직 · 키 GX)
* 검사에 걸린 것은 그 줄·자리를 짚어 쉬운 말로 보임 — 서버 글 그대로(`문제`)
* ========================================================================== */
import {
createButton,
createInputField,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
draftLogic,
type DraftAnswer,
type DraftDecided,
type DraftHead,
type DraftProblem,
type DraftVar,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
import { varRow, type VarRow } from "./M01_MasterData_UI_LogicLab_New_Var";
import "./M01_MasterData_UI_LogicLab_New.css";
/** 보기 글 — 넓이 → 품 → 인부 한 줄 + 잡재료비 덧줄 */
const SAMPLE = `# 보기 — 분석을 누른 뒤 변수마다 무엇인지 골라 주세요
넓이 = 길이 * 폭
인부 = 넓이 * 품
잡재료비 = 노무비 * 0.05
공사비 = 인부 * 보통인부 + 잡재료비
`;
export interface NewOptions {
/** 저장 끝 — 새 자체 로직의 키 */
onSaved: (key: string) => void;
onClose: () => void;
}
export function buildNewLogic(opts: NewOptions): HTMLElement {
const decided: Record<string, DraftDecided> = {};
const rows = new Map<string, VarRow>();
const values: Record<string, string> = {};
let analyzed = ""; // 분석한 식 — 그 뒤 글이 바뀌면 다시 분석하라고 알림
let answer: DraftAnswer | null = null;
let calcSig = "";
let seq = 0;
const text = el("textarea", {
className: "m01lab-new__text",
attrs: { rows: "7", spellcheck: "false", "aria-label": tn("Step1") },
});
text.value = SAMPLE;
const stale = el("p", {
className: "m01-logic__muted",
text: tn("Stale"),
attrs: { hidden: "" },
});
const varsHost = el("div", { className: "m01lab-new__vars" });
const problemHost = el("div", { className: "m01lab-new__problems" });
const calcHost = el("div", { className: "m01lab__calc m01lab-new__calc" });
const nameBox = createInputField({
type: "text",
label: tn("Name"),
onInput: () => onEdit(),
});
nameBox.input.setAttribute("data-key", "name");
const unitBox = createInputField({
type: "text",
label: tn("ResultUnit"),
placeholder: "원/㎡",
onInput: () => onEdit(),
});
unitBox.input.setAttribute("data-key", "unit");
const ownerBox = createInputField({ type: "text", label: tn("Owner") });
const state = el("p", { className: "m01-logic__muted" });
const save = createButton({ label: tn("Save"), onClick: () => void doSave() });
save.setAttribute("data-act", "save");
save.disabled = true;
const head = (): DraftHead | undefined => {
const 이름 = nameBox.input.value.trim();
const 결과단위 = unitBox.input.value.trim();
return 이름 && 결과단위 ? { 이름, 결과단위, 출처: "자체" } : undefined;
};
/** 문제가 걸린 줄을 그대로 보이고 자리 글자에 표시 · 아래에 쉬운 말 목록 */
const drawProblems = (problems: DraftProblem[]): void => {
if (!analyzed) return problemHost.replaceChildren();
const lines = analyzed.split("\n");
const flagged = new Map<number, DraftProblem[]>();
for (const p of problems) {
if (p.줄) flagged.set(p.줄, [...(flagged.get(p.줄) ?? []), p]);
}
const shown = [...flagged.keys()]
.sort((a, b) => a - b)
.filter((n) => n <= lines.length)
.map((n) => {
const raw = lines[n - 1];
const at = flagged.get(n)?.find((p) => p.자리 !== null)?.자리 ?? -1;
return el("div", {
className: "m01lab-new__src",
children: [
el("span", { className: "m01lab-new__no", text: String(n) }),
at >= 0
? el("code", {
children: [
raw.slice(0, at),
el("mark", { text: raw.slice(at, at + 1) }),
raw.slice(at + 1),
],
})
: el("code", { text: raw }),
],
});
});
const items = problems.map((p) =>
el("li", {
className: p.갈래 === "덜정함" ? "m01lab-new__soft" : "m01lab-new__bad",
attrs: { "data-kind": p.갈래 },
text: `${p.줄 ? `${tn("Line", { n: p.줄 })} · ` : ""}${p.말}`,
}),
);
problemHost.replaceChildren(
el("h4", { text: tn("Problems") }),
...(items.length
? [...shown, el("ul", { children: items })]
: [el("p", { className: "m01-logic__muted", text: tn("AllGood") })]),
);
};
const drawCalc = (): void => {
const row = answer?.logic ?? null;
save.disabled = !row || (answer?.문제.length ?? 1) > 0;
state.textContent = !answer
? ""
: answer.문제.some((p) => p.갈래 !== "덜정함")
? tn("SaveBlocked")
: !head()
? tn("NeedHead")
: answer.다됨
? ""
: tn("Undecided");
if (!row) {
calcSig = "";
calcHost.replaceChildren();
return;
}
const sig = JSON.stringify(row.입력 ?? []);
if (sig === calcSig) return; // 입력 칸이 그대로면 넣은 값·결과를 안 지우려고 다시 안 그림
calcSig = sig;
buildCalc(calcHost, {
savedKey: null,
row: { ...row, 키: row.키 ?? "" }, // 아직 번호 없는 새 초안 — 키 칸은 비워 보냄
dirty: () => true,
values,
onLines: () => undefined,
});
};
const drawVars = (vars: DraftVar[]): void => {
rows.clear();
const shown = vars.filter((v) => v.갈래 !== "비목합");
for (const name of Object.keys(decided)) {
if (!shown.some((v) => v.이름 === name)) delete decided[name];
}
const result = answer?.결과 ?? "";
const order = [
...shown.filter((v) => v.갈래 === "미정"),
...shown.filter((v) => v.갈래 !== "미정"),
];
for (const v of order) rows.set(v.이름, varRow(v, v.이름 === result, { decided, onEdit }));
varsHost.replaceChildren(...[...rows.values()].map((r) => r.root));
};
const evaluate = async (rebuild: boolean): Promise<void> => {
if (!analyzed) return;
const mine = ++seq;
try {
const got = await draftLogic({ text: analyzed, decided, head: head() });
if (mine !== seq) return; // 최신 응답만
answer = got;
if (rebuild) drawVars(got.변수);
for (const v of got.변수) rows.get(v.이름)?.update(v);
drawProblems(got.문제);
drawCalc();
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
let timer = 0;
function onEdit(): void {
window.clearTimeout(timer);
timer = window.setTimeout(() => void evaluate(false), 250);
}
const analyzeButton = createButton({
label: tn("Analyze"),
onClick: () => {
analyzed = text.value;
stale.hidden = true;
void evaluate(true);
},
});
analyzeButton.setAttribute("data-act", "analyze");
text.addEventListener("input", () => (stale.hidden = !analyzed || text.value === analyzed));
async function doSave(): Promise<void> {
const h = head();
if (!h) return void showToast(tn("NeedHead"), "info");
save.disabled = true;
showLoadingOverlay(); // 서버가 파일을 검사하며 저장하느라 몇 초 걸림
try {
const owner = ownerBox.input.value.trim();
const done = await draftLogic({
text: analyzed,
decided,
head: h,
save: true,
...(owner ? { owner } : {}),
});
showToast(tn("Saved", { v: done.key ?? "" }), "success");
if (done.key) opts.onSaved(done.key);
} catch (error) {
const detail = error instanceof ApiError ? (error.detail as { 문제?: DraftProblem[] }) : null;
if (detail?.문제) drawProblems(detail.문제);
else showToast(error instanceof Error ? error.message : tn("Failed"), "error");
save.disabled = false;
} finally {
hideLoadingOverlay();
}
}
const section = (title: string, ...children: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01lab__box m01lab-new__step",
children: [el("h3", { text: title }), ...children],
});
return el("div", {
className: "m01lab-new",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h2", { text: tn("Title") }),
createButton({ label: tn("Close"), variant: "ghost", onClick: opts.onClose }),
],
}),
section(
tn("Step1"),
el("p", { className: "m01-logic__muted", text: tn("Guide") }),
text,
el("div", { className: "m01lab-new__row", children: [analyzeButton, stale] }),
),
section(tn("Step2"), varsHost, problemHost),
section(
tn("Step3"),
el("div", {
className: "m01lab-new__row",
children: [nameBox.root, unitBox.root, ownerBox.root],
}),
),
section(tn("Step4"), calcHost, state, save),
],
});
}
@@ -1,218 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Find.ts
* 새 로직 만들기의 고르기 창 둘
* ① 마스터 요소 — 종류(인력·재료·기계) → 구분 → 상세구분 → 이름 찾기 → 한 줄 고르기
* ② 표 찾기 — 표(소요량·계수)와 값 칸을 고른 뒤 표의 조건 칸마다 식을 적음
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubsAll,
type FileInfo,
type SubInfo,
} from "./M01_MasterData_Api_Fetch";
import type { DraftFind } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { loadTable } from "./M01_MasterData_UI_Logic_Note";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface PickedElement {
ref: string;
/** 이름 + 규격 */
name: string;
/** 수량에 쓰는 단위(「원/일」 → 「일」) */
unit: string;
group: string;
}
const GROUPS = ["인력", "재료", "기계"];
const SHOWN = 50;
/** 창 뼈대 — 제목 · 닫기 · 몸통 */
function shell(title: string, body: HTMLElement[]): { close: () => void } {
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": title },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: title }),
createButton({ label: tn("Close"), variant: "ghost", onClick: close }),
],
}),
...body,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
return { close };
}
/** 그룹의 파일 — 재료는 자재품목(구분·상세구분이 있는 것) */
async function fileOf(group: string): Promise<FileInfo | undefined> {
const files = await fetchFiles(group);
return group === "재료" ? (files.find((f) => f.file.includes("자재품목")) ?? files[0]) : files[0];
}
const options = (values: string[]): { value: string; text: string }[] => [
{ value: "", text: tn("All") },
...values.map((v) => ({ value: v, text: v })),
];
export function openElementPick(done: (picked: PickedElement) => void): void {
let file: FileInfo | undefined;
let subs: SubInfo[] = [];
const groupBox = createSelectField({
options: GROUPS.map((g) => ({ value: g, text: g })),
value: GROUPS[0],
compact: true,
label: tn("Group"),
});
const subBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Sub"),
});
const detailBox = createSelectField({
options: options([]),
value: "",
compact: true,
label: tn("Detail"),
});
const searchField = createInputField({ type: "search", placeholder: tn("Search") });
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const win = shell(tn("K_master"), [
el("div", {
className: "m01-logic__pick-bar",
children: [groupBox.root, subBox.root, detailBox.root, searchField.root, count],
}),
list,
]);
let seq = 0;
const load = async (): Promise<void> => {
if (!file) return;
const mine = ++seq;
try {
const got = await fetchRows(
file.file,
1,
SHOWN,
searchField.input.value.trim(),
false,
subBox.select.value,
detailBox.select.value,
);
if (mine !== seq) return;
count.textContent = tn("Count", { n: got.total, m: Math.min(got.total, SHOWN) });
list.replaceChildren(
...got.rows.map((row) => {
const name = `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const unitText = String(row["단위"] ?? "");
const unit = groupBox.select.value === "인력" ? "인" : (unitText.split("/").pop() ?? "");
const button = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-ref": String(row["키"]) },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: name }),
el("span", { className: "m01-logic__muted", text: String(row["키"]) }),
],
}),
el("span", {
className: "m01-logic__money",
text: `${formatNumber(row["값"])} ${unitText}`.trim(),
}),
],
});
button.addEventListener("click", () => {
win.close();
done({ ref: String(row["키"]), name, unit, group: groupBox.select.value });
});
return button;
}),
);
} catch (error) {
showToast(error instanceof Error ? error.message : tn("Failed"), "error");
}
};
const changeGroup = async (): Promise<void> => {
file = await fileOf(groupBox.select.value);
subs = file ? (await fetchSubsAll({ file: file.file })).subs : [];
subBox.setOptions(options(subs.map((s) => s.name)), "");
detailBox.setOptions(options([]), "");
detailBox.select.disabled = true;
await load();
};
groupBox.select.addEventListener("change", () => void changeGroup());
subBox.select.addEventListener("change", () => {
const details = subs.find((s) => s.name === subBox.select.value)?.details ?? [];
detailBox.setOptions(options(details), "");
detailBox.select.disabled = details.length === 0;
void load();
});
detailBox.select.addEventListener("change", () => void load());
let timer = 0;
searchField.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void load(), 250);
});
void changeGroup();
}
/** 표 찾기 — 표·값 칸을 고른 뒤 조건 칸마다 식을 받음 · 표의 조건 칸이 없으면 바로 끝 */
export function openTablePick(done: (find: DraftFind, label: string) => void): void {
openPicker("table", "소요량", (item, column) => {
void (async () => {
const table = await loadTable(item.ref);
const conds = Object.keys(table?.조건 ?? {});
const label = `${item.이름 ?? item.ref} · ${item.ref} → ${column ?? ""}`;
const find = { 표: item.ref, 값칸: column ?? "", 조건: {} as Record<string, string> };
if (!conds.length) {
done(find, label);
return;
}
const inputs = conds.map((c) => {
const box = createInputField({ type: "text", value: c });
box.input.setAttribute("data-cond", c);
return { c, box };
});
const win = shell(tn("TableTitle"), [
el("p", { className: "m01-logic__muted", text: label }),
el("p", { text: tn("Cond") }),
...inputs.map(({ c, box }) =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: c }), box.root],
}),
),
createButton({
label: tn("Ok"),
onClick: () => {
for (const { c, box } of inputs) find.조건[c] = box.input.value.trim() || c;
win.close();
done(find, label);
},
}),
]);
})();
});
}
@@ -1,71 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Text.ts
* 「식으로 새 로직 만들기」 글자 — [한국어, 영어]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Open: ["식으로 새로 만들기", "New from formula"],
Title: ["식으로 새 로직 만들기", "New logic from a formula"],
Close: ["닫기", "Close"],
Step1: ["① 식 적기", "① Write the formula"],
Guide: [
"한 줄에 「이름 = 식」 · 마지막 줄 왼쪽이 이 로직의 결과 · 「#」 로 시작한 줄은 건너뜀",
"One line each as “name = formula” · the last line's left side is the result · lines starting with # are skipped",
],
Analyze: ["분석", "Analyze"],
Stale: ["식이 바뀌었음 — 다시 [분석] 을 눌러 주세요", "Formula changed — press Analyze again"],
Step2: ["② 변수 정하기", "② Decide each variable"],
Step3: ["③ 이름·단위", "③ Name and unit"],
Step4: ["④ 시험 계산 · 저장", "④ Trial calculation and save"],
Problems: ["고칠 곳", "To fix"],
Line: ["{n}번 줄", "Line {n}"],
AllGood: ["검사에 걸린 것 없음", "No problems found"],
Result: ["결과", "Result"],
Undecided: ["아직 정하지 않은 칸이 있음", "Some cells are not decided yet"],
Kind: ["무엇인가", "What is it"],
K_master: ["마스터 요소", "Master element"],
K_input: ["설계 입력", "Design input"],
K_fixed: ["고정값", "Fixed value"],
K_table: ["표 찾기", "Table lookup"],
Pick: ["고르기", "Pick"],
Repick: ["다시 고르기", "Pick again"],
NotPicked: ["아직 안 골랐음", "Not picked"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost item"],
CostNone: ["넣지 않음", "None"],
CostExtra: ["덧줄 비목", "Extra-line cost item"],
Value: ["값", "Value"],
Note: ["설명", "Note"],
Derived: ["식으로 정해짐", "Set by the formula"],
SumName: ["비목 합 — 덧줄에서만 쓰는 이름", "Cost total — only for extra lines"],
Name: ["로직 이름", "Logic name"],
ResultUnit: ["결과 단위", "Result unit"],
Owner: ["소유", "Owner"],
Save: ["저장", "Save"],
Saved: ["저장했음 — 「{v}」", "Saved — “{v}”"],
SaveBlocked: ["위 고칠 곳을 먼저 고치세요", "Fix the problems above first"],
NeedHead: ["이름과 결과 단위를 적어야 시험 계산·저장이 됨", "Enter the name and result unit"],
Group: ["종류", "Kind"],
Sub: ["구분", "Group"],
Detail: ["상세구분", "Detail"],
All: ["(전체)", "(All)"],
Search: ["이름 찾기", "Search name"],
Count: ["{n} 개 중 앞 {m} 개", "first {m} of {n}"],
TableTitle: ["표 찾기 — 표와 조건", "Table lookup — table and conditions"],
TablePick: ["표 고르기", "Pick a table"],
TableCol: ["값 칸", "Value column"],
Cond: ["조건 — 표의 칸마다 식(설계 값 이름)", "Conditions — a formula per table column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
Failed: ["못 함", "Failed"],
} as const satisfies Record<string, readonly [string, string]>;
export type NewTextKey = keyof typeof TEXT;
export function tn(key: NewTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
@@ -1,178 +0,0 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_New_Var.ts
* 변수 한 칸 — 무엇인지(마스터 요소 / 설계 입력 / 고정값 / 표 찾기) · 단위 · 비목을 고름
* 고른 것은 `decided[이름]` 에 쌓음(서버 계약 `_화면_계약.md` 7장의 `decided` 그대로)
* ========================================================================== */
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { DraftDecided, DraftVar } from "./M01_MasterData_UI_Logic_Api";
import { openElementPick, openTablePick } from "./M01_MasterData_UI_LogicLab_New_Find";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
export interface VarCtx {
decided: Record<string, DraftDecided>;
/** 고른 것이 바뀜 — 화면이 검사를 다시 부름 */
onEdit: () => void;
}
export interface VarRow {
root: HTMLElement;
/** 서버가 다시 준 변수(식으로 정해진 단위 등)를 반영 */
update: (v: DraftVar) => void;
}
const KINDS: [string, () => string][] = [
["마스터요소", () => tn("K_master")],
["설계입력", () => tn("K_input")],
["고정값", () => tn("K_fixed")],
["표찾기", () => tn("K_table")],
];
const COSTS = ["노무비", "재료비", "경비"];
/** 요소 종류 → 비목 첫 제안 */
const COST_OF: Record<string, string> = { 인력: "노무비", 재료: "재료비", 기계: "경비" };
const costOptions = (): { value: string; text: string }[] => [
{ value: "", text: tn("CostNone") },
...COSTS.map((c) => ({ value: c, text: c })),
];
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
export function varRow(v: DraftVar, isResult: boolean, ctx: VarCtx): VarRow {
const name = v.이름;
const info = el("span", { className: "m01-logic__muted m01lab-new__derived" });
const body = el("div", { className: "m01lab-new__var-body" });
const root = el("div", {
className: "m01lab-new__var",
attrs: { "data-var": name },
children: [
el("div", {
className: "m01lab-new__var-head",
children: [el("strong", { text: name }), info],
}),
body,
],
});
const now = (): DraftDecided => (ctx.decided[name] ??= {});
const set = (patch: DraftDecided): void => {
ctx.decided[name] = { ...now(), ...patch };
for (const [k, val] of Object.entries(ctx.decided[name])) {
if (val === "" || val === undefined) delete (ctx.decided[name] as Record<string, unknown>)[k];
}
ctx.onEdit();
};
const text = (label: string, key: "단위" | "값" | "설명", wide = false): HTMLElement => {
const box = createInputField({
type: "text",
value: now()[key] ?? "",
onInput: (val) => set({ [key]: val.trim() }),
});
box.input.setAttribute("data-key", key);
const cell = field(label, box.root);
if (wide) cell.classList.add("m01lab-new__wide");
return cell;
};
let picked = ""; // 마스터 요소·표 찾기에서 고른 것의 글
const draw = (): void => {
const kind = now().무엇 ?? "";
if (v.갈래 === "비목합") {
body.replaceChildren(el("span", { className: "m01-logic__muted", text: tn("SumName") }));
return;
}
if (v.갈래 === "정의됨") {
if (isResult) return body.replaceChildren();
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("CostExtra"),
onChange: (val) => set({ 비목: val }),
});
body.replaceChildren(cost.root);
return;
}
const kindBox = createSelectField({
options: [
{ value: "", text: "—" },
...KINDS.map(([value, label]) => ({ value, text: label() })),
],
value: kind,
compact: true,
label: tn("Kind"),
onChange: (val) => {
ctx.decided[name] = val ? { 무엇: val, ...(now().단위 ? { 단위: now().단위 } : {}) } : {};
picked = "";
draw();
ctx.onEdit();
},
});
const parts: HTMLElement[] = [kindBox.root];
if (kind === "마스터요소") {
const cost = createSelectField({
options: costOptions(),
value: now().비목 ?? "",
compact: true,
label: tn("Cost"),
onChange: (val) => set({ 비목: val }),
});
const choose = createButton({
label: now().요소 ? tn("Repick") : tn("Pick"),
variant: "ghost",
onClick: () =>
openElementPick((p) => {
picked = `${p.name} · ${p.ref}`;
set({ 요소: p.ref, 단위: p.unit, 비목: COST_OF[p.group] ?? now().비목 });
draw();
}),
});
choose.setAttribute("data-act", "pick-element");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || now().요소 || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
cost.root,
);
} else if (kind === "설계입력") {
parts.push(text(tn("Unit"), "단위"), text(tn("Note"), "설명", true));
} else if (kind === "고정값") {
parts.push(text(tn("Value"), "값"), text(tn("Unit"), "단위"));
} else if (kind === "표찾기") {
const choose = createButton({
label: now().찾기 ? tn("Repick") : tn("TablePick"),
variant: "ghost",
onClick: () =>
openTablePick((find, label) => {
picked = label;
set({ 찾기: find });
draw();
}),
});
choose.setAttribute("data-act", "pick-table");
parts.push(
el("div", {
className: "m01lab-new__picked",
children: [choose, el("span", { text: picked || tn("NotPicked") })],
}),
text(tn("Unit"), "단위"),
);
}
body.replaceChildren(...parts);
};
draw();
const update = (next: DraftVar): void => {
v = next;
info.textContent =
next.갈래 === "정의됨"
? `${tn("Derived")}${next.단위 ? ` · ${next.단위}` : ""}`
: (next.단위 ?? "");
};
update(v);
return { root, update };
}
@@ -81,7 +81,7 @@ const TEXT = {
"지금 값으로는 걸린 줄이 없음 — 값을 바꿔 보거나, 이 표를 안 쓰는 갈래일 수 있음",
"No row matches the current values — change them, or this table is on an unused branch",
],
Copy_Open: ["본떠 만들기", "Copy as mine"],
Copy_Open: ["로직 복사/생성", "Copy / create logic"],
Copy_NeedOpen: ["먼저 로직을 고르세요", "Pick a logic first"],
Copy_Title: ["복사해서 만들기", "Copy to edit"],
Copy_Name: ["새 이름", "New name"],