Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_7

This commit is contained in:
2026-10-01 09:39:08 +09:00
5 changed files with 1265 additions and 17 deletions
+413 -15
View File
@@ -1,33 +1,431 @@
/* =============================================================================
* M01_EasyCalc_Entry.ts
* 쉬운 단가산출 시험 길 — 들어가는 단추 + 빈 창(틀만). 이 폴더째 지워도 됨.
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
import {
listEasy,
openEasy,
plainError,
type EasyListItem,
type EasyOpened,
} from "./M01_EasyCalc_Api";
import type { EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Style.css";
const TEXT = {
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
Title: ["쉬운 단가산출", "Easy unit-price"],
Ready: ["준비 중", "Coming soon"],
CloseWindow: ["창 닫기", "Close window"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
Start_Mine: ["내가 만든 것", "Made by me"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Base: ["품셈 기본", "Standard"],
Mine: ["내가 만든 것", "Made by me"],
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
Per: ["{u} 당", "per {u}"],
Name: ["이름", "Name"],
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
Spec: ["크기", "Size"],
Unit: ["단위", "Unit"],
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
Undo: ["되돌리기", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
BaseNote: [
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
"Standard items are read-only — use the button below to make your own copy",
],
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
Trial: ["설계 때 정하는 값", "Values set at design time"],
Cards: ["무엇이 들어가나", "What goes in"],
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
NotYet: ["준비 중", "Coming soon"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
type TextKey = keyof typeof TEXT;
/** 「식으로 새로 만들기」 옆에 놓는 단추 — 누르면 빈 창 */
export function createEasyCalcButton(): HTMLButtonElement {
return createButton({
label: te("Open"),
variant: "filled",
onClick: () =>
openModal({
title: te("Title"),
closeLabel: te("Close"),
mount: (body) =>
body.append(el("p", { className: "m01-easycalc__ready", text: te("Ready") })),
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
/** 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */
const loadList = (): Promise<EasyListItem[]> =>
listEasy("").catch(() =>
fetchLogics().then((all) =>
all.map((x) => {
const isBase = !x.키.startsWith("GX");
const canOpen = x.결과단위.startsWith("원/");
return {
key: x.키,
name: x.이름,
isBase,
owner: isBase ? te("Base") : te("Mine"),
resultUnit: x.결과단위,
canOpen,
whyNot: canOpen ? null : te("CannotOpen"),
where: `${x.구분} ${x.상세구분}`.trim(),
};
}),
),
);
const loadOne = (key: string): Promise<EasyOpened> =>
openEasy(key).catch(() =>
fetchLogic(key).then((one) => ({
row: one.logic,
version: one.version,
isBase: !one.logic.키.startsWith("GX"),
trial: {},
})),
);
const plain = (error: unknown): string => {
try {
return plainError(error);
} catch {
return error instanceof Error ? error.message : String(error);
}
};
/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
const blankRow = (resultUnit: string): LogicRow => ({
키: "",
원문번호: "",
이름: "",
결과단위: resultUnit,
출처: "자체",
입력: [],
중간: [],
호표: [],
덧줄: [],
끝수: null,
});
const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({
key,
version: opened.version,
isBase: opened.isBase,
name: opened.row.이름,
spec: "",
resultUnit: opened.row.결과단위,
inputs: opened.row.입력 ?? [],
cards: [],
row: opened.row,
});
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */
const amountOf = (resultUnit: string): string => {
const unit = unitOf(resultUnit);
return /^\d/.test(unit) ? unit : `1${unit}`;
};
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
export function createEasyCalcButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
}
function openEasyCalc(): void {
let body: HTMLElement;
let paper: EasyDoc | null = null;
let dirty = false;
let logics: EasyListItem[] | null = null;
let query = "";
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
const mayLeave = async (): Promise<boolean> =>
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const notYet = (): void => showToast(te("NotYet"), "info");
/* ---------- 시작 화면 ---------- */
const listRow = (item: EasyListItem): HTMLElement => {
const ok = item.canOpen;
const row = el("button", {
className: "m01-easycalc__item",
attrs: { type: "button" },
children: [
el("span", { className: "m01-easycalc__item-name", text: item.name }),
createTag(item.owner, item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-easycalc__item-unit",
text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit,
}),
...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]),
],
});
row.disabled = !ok;
row.title = item.where;
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
dirty = false;
const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") });
const mineHost = el("div", { className: "m01-easycalc__list" });
const foundHost = el("div", { className: "m01-easycalc__list" });
const search = createInputField({
type: "search",
placeholder: te("Start_Search"),
value: query,
onInput: (v) => {
query = v;
drawFound();
},
});
search.root.classList.add("m01-easycalc__search");
const drawFound = (): void => {
if (!logics) return;
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hits = words.length
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
: logics;
status.textContent = !hits.length
? te("Start_None")
: hits.length > SHOWN
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
: "";
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
};
body.replaceChildren(
el("div", {
className: "m01-easycalc__start",
children: [
el("div", {
className: "m01-easycalc__start-col",
children: [
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
search.root,
el("h4", { text: te("Start_Found") }),
status,
foundHost,
],
}),
el("div", {
className: "m01-easycalc__start-col m01-easycalc__start-side",
children: [
createBigButton(te("Start_Blank"), () => openBlank()),
el("h4", { text: te("Start_Mine") }),
mineHost,
],
}),
],
}),
);
search.input.focus();
try {
logics ??= await loadList();
} catch (error) {
status.textContent = te("LoadFailed", { v: plain(error) });
return;
}
const mine = logics.filter((x) => !x.isBase);
mineHost.replaceChildren(
...(mine.length
? mine.map(listRow)
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
);
drawFound();
};
/* ---------- 고치는 화면 ---------- */
const openBlank = (): void => {
const row = blankRow(unitChoices()[0] ?? "원/㎡");
paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null };
showPaper();
};
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") }));
try {
paper = docOf(await loadOne(key), key);
showPaper();
} catch (error) {
showToast(plain(error), "error");
void showStart();
}
};
/** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */
const unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u);
};
const showPaper = (): void => {
const p = paper;
if (!p) return;
const readOnly = p.isBase;
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = true;
const total = el("div", { className: "m01-easycalc__total" });
const drawTotal = (): void => {
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
};
const touched = (): void => {
dirty = true;
unsaved.hidden = false;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: p.name,
onInput: (v) => {
p.name = v;
touched();
},
});
const spec = createInputField({
label: te("Spec"),
value: p.spec,
onInput: (v) => {
p.spec = v;
touched();
},
});
const units = unitChoices();
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
const unit = createSelectField({
label: te("Unit"),
options: units.map((u) => ({ value: u, text: unitOf(u) })),
value: p.resultUnit,
disabled: readOnly,
onChange: (v) => {
p.resultUnit = v;
drawTotal();
touched();
},
});
name.input.disabled = readOnly;
spec.input.disabled = readOnly;
name.root.classList.add("m01-easycalc__name");
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
});
const actions = readOnly
? [close]
: [
undo,
createButton({ label: te("Save"), onClick: notYet }),
...(p.key && !p.isBase
? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })]
: []),
close,
];
const section = (title: string, extra = ""): HTMLElement =>
el("section", {
className: `m01-easycalc__part ${extra}`.trim(),
children: [
el("h4", { text: title }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
],
});
const sheet = el("details", {
className: "m01-easycalc__part m01-easycalc__sheet",
children: [
el("summary", { text: te("Sheet") }),
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
],
});
drawTotal();
body.replaceChildren(
el("div", {
className: "m01-easycalc__strip",
children: [
name.root,
spec.root,
unit.root,
total,
unsaved,
el("div", { className: "m01-easycalc__actions", children: actions }),
],
}),
...(readOnly
? [
el("div", {
className: "m01-easycalc__base",
children: [
el("p", { text: te("BaseNote") }),
createBigButton(te("BaseCopy"), notYet),
],
}),
]
: []),
section(te("Trial"), "m01-easycalc__trial"),
section(te("Cards"), "m01-easycalc__cards"),
sheet,
);
if (!readOnly) name.input.focus();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
openModal({
title: te("Title"),
closeLabel: te("CloseWindow"),
dialogClass: "m01-easycalc",
beforeClose: mayLeave,
mount: (host) => {
body = host;
void showStart();
},
});
}
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m01-easycalc__big");
return button;
}
@@ -0,0 +1,100 @@
.m01-easypick {
--modal-max: 86vh;
width: min(760px, 94vw);
font-size: var(--text-body-lg, 1.125rem);
}
.m01-easypick__ask {
margin: 0;
font-size: 1.35em;
color: var(--color-text);
}
.m01-easypick__muted {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easypick__kinds {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--spacing-12);
}
.m01-easypick__kind,
.m01-easypick__row {
display: flex;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
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-easypick__kind {
flex-direction: column;
min-height: 96px;
}
.m01-easypick__kind strong {
font-size: 1.3em;
}
.m01-easypick__kind span {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-easypick__kind:hover,
.m01-easypick__row:hover,
.m01-easypick__kind:focus-visible,
.m01-easypick__row:focus-visible {
border-color: var(--color-accent);
outline: 3px solid var(--color-accent);
outline-offset: 1px;
}
.m01-easypick__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m01-easypick__bar > :last-child {
flex: 1 1 200px;
}
.m01-easypick__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
max-height: 46vh;
overflow: auto;
}
.m01-easypick__row {
align-items: center;
justify-content: space-between;
min-height: 52px;
}
.m01-easypick__name {
flex: 1 1 auto;
min-width: 0;
}
.m01-easypick__unit,
.m01-easypick__price {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-easypick__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m01-easypick__chosen {
margin: 0;
font-size: 1.15em;
}
.m01-easypick__rough {
min-height: 1.4em;
margin: 0;
color: var(--color-accent);
font-weight: 600;
}
@@ -0,0 +1,456 @@
/* =============================================================================
* M01_EasyCalc_Pick.ts
* 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음.
* 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절.
* 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨.
* ========================================================================== */
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 { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api";
import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api";
import type { PickResult } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Pick.css";
type Kind = PickResult["kind"];
const TEXT = {
Title: ["넣을 것 고르기", "Pick what to add"],
Close: ["닫기", "Close"],
Back: ["← 뒤로", "← Back"],
AskKind: ["무엇을 넣을까요?", "What do you want to add?"],
AskWhich: ["어떤 것인가요?", "Which one?"],
AskQty: ["얼마나 쓰나요?", "How much?"],
Person: ["사람", "People"],
Material: ["재료", "Material"],
Machine: ["기계", "Machine"],
Logic: ["다른 일위대가", "Another unit-price job"],
PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"],
MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"],
MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"],
LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"],
Search: ["이름을 쳐서 찾기", "Type a name to search"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 것 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
NoPrice: ["단가 없음", "No price"],
PerDay: ["하루 {p} 원", "{p} won per day"],
PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"],
Picked: ["고른 것", "Chosen"],
QtyPlace: ["예: 0.072", "e.g. 0.072"],
QtyLabel: ["{u} (숫자)", "{u} (number)"],
Rough: ["금액 어림 약 {m} 원", "About {m} won"],
Add: ["넣기", "Add"],
QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
TEXT[key][currentLanguageIndex] as string,
);
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
const MAT_FILE = "재료_자재품목.json";
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
interface Candidate {
name: string;
spec: string;
unit: string;
/** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */
price: number | null;
/** 줄 오른쪽 글(단가 표시) */
priceText: string;
tag?: string;
make: () => Promise<Omit<PickResult, "qty">>;
}
interface Filter {
q: string;
sub: string;
detail: string;
}
interface Source {
title: string;
desc: string;
/** 구분 · 상세구분 칸을 보일지(재료만) */
grouped?: boolean;
/** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
}
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const SOURCES: Partial<Record<Kind, Source>> = {
person: {
title: te("Person"),
desc: te("PersonDesc"),
load: async (f) => {
const { total, items } = await fetchPick("job", f.q);
return {
total,
items: items.map((it) => {
const price = num(it.값);
return {
name: it.이름,
spec: it.규격,
unit: "인",
price,
priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }),
make: async () => ({
kind: "person",
name: it.이름,
spec: it.규격,
unit: "인",
element: it.ref,
}),
};
}),
};
},
},
material: {
title: te("Material"),
desc: te("MaterialDesc"),
grouped: true,
load: async (f) => {
if (!f.q && !f.sub) return null;
const found = f.q
? await fetchPick("price", f.q).then((r) => ({
total: r.total,
items: r.items
.filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub)
.map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })),
}))
: await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({
total: r.total,
items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })),
}));
return {
total: found.total,
items: found.items.map((it) => {
const name = it.이름 ?? "";
const spec = it.규격 ?? "";
const unit = typeof it.단위 === "string" ? it.단위 : "";
const price = num(it.값);
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
const detail = (it as { 상세구분?: string }).상세구분 ?? f.detail;
if (detail) element["상세구분"] = detail;
element["검색어"] = `${name} ${spec}`.trim();
return {
name,
spec,
unit,
price,
priceText:
price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }),
tag: element["구분"],
make: async () => ({ kind: "material", name, spec, unit, element }),
};
}),
};
},
},
machine: {
title: te("Machine"),
desc: te("MachineDesc"),
load: async (f) => {
const items = await listMachines(f.q);
return {
total: items.length,
items: items.map((m) => ({
name: m.이름,
spec: m.규격,
unit: m.단위,
price: null,
priceText: "",
make: async () => {
const { element, needInputs } = await machineElement(m.분류번호);
return {
kind: "machine",
name: m.이름,
spec: m.규격,
unit: m.단위,
element,
needInputs,
};
},
})),
};
},
},
logic: {
title: te("Logic"),
desc: te("LogicDesc"),
load: async (f) => {
// 돈으로 나오는 일위대가만 — 「원/㎡」 꼴
const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/"));
return {
total: items.length,
items: items.map((e) => {
const unit = e.resultUnit.slice(2);
return {
name: e.name,
spec: "",
unit,
price: null,
priceText: "",
tag: e.owner,
make: async () => ({
kind: "logic",
name: e.name,
spec: "",
unit,
element: `로직(${e.key})`,
}),
};
}),
};
},
},
};
/** 물음 셋 — 고른 것 · 취소는 null */
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: PickResult | null): void => {
if (!settled) resolve(r);
settled = true;
};
openModal({
title: te("Title"),
closeLabel: te("Close"),
dialogClass: "m01-easypick",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const done = (r: PickResult): void => (finish(r), close());
const stepKinds = (): void => {
body.replaceChildren(
el("h4", { className: "m01-easypick__ask", text: te("AskKind") }),
kindButtons(),
);
body.querySelector<HTMLElement>("button")?.focus();
};
const kindButtons = (): HTMLElement => {
const order: Kind[] = ["person", "material", "machine", "logic"];
const grid = el("div", { className: "m01-easypick__kinds" });
order.forEach((k, i) => {
const src = SOURCES[k];
const b = el("button", {
className: "m01-easypick__kind",
attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) },
children: [
el("strong", { text: src?.title ?? labelOf(k) }),
el("span", { text: src?.desc ?? descOf(k) }),
],
});
b.addEventListener("click", () => stepList(k, true));
grid.append(b);
});
grid.addEventListener("keydown", (ev) => {
const k = order[Number(ev.key) - 1];
if (k) stepList(k, true);
});
return grid;
};
const stepList = (k: Kind, canBack: boolean): void => {
const src = SOURCES[k];
if (!src) return;
const back = canBack ? stepKinds : null;
const search = createInputField({ type: "search", placeholder: te("Search") });
const filter: Filter = { q: "", sub: "", detail: "" };
const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } });
const count = el("span", { className: "m01-easypick__muted" });
const bar: HTMLElement[] = [search.root];
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
count.textContent = "";
list.replaceChildren(
el("p", { className: "m01-easypick__muted", text: te("Loading") }),
);
try {
const found = await src.load(filter);
if (mine !== seq) return; // 최신 응답만
if (!found)
return void list.replaceChildren(
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
);
count.textContent = te("Count", { n: found.total, m: found.items.length });
list.replaceChildren(
...(found.items.length
? found.items.map((c) =>
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
)
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
);
} catch {
if (mine === seq) showToast(te("Failed"), "error");
}
};
if (src.grouped) bar.unshift(...subFilters(filter, load));
let timer = 0;
search.input.addEventListener("input", () => {
filter.q = search.input.value.trim();
window.clearTimeout(timer);
timer = window.setTimeout(load, 250);
});
body.replaceChildren(
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }),
el("div", { className: "m01-easypick__bar", children: bar }),
count,
list,
);
body.onkeydown = (ev) => {
if (back && ev.altKey && ev.key === "ArrowLeft") back();
else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev);
};
search.input.focus();
void load();
};
const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => {
const qty = createInputField({
type: "text",
label: te("QtyLabel", { u: c.unit || "—" }),
placeholder: te("QtyPlace"),
});
qty.input.inputMode = "decimal";
const rough = el("p", { className: "m01-easypick__rough" });
const parse = (): number | null => {
const n = Number(qty.input.value.trim().replace(/,/g, ""));
return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null;
};
const refresh = (): void => {
const n = parse();
rough.textContent =
n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : "";
qty.setError(undefined);
};
const submit = async (): Promise<void> => {
const n = parse();
if (n === null) return qty.setError(te("QtyBad"));
done({ ...(await c.make()), qty: n });
};
qty.input.addEventListener("input", refresh);
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
body.replaceChildren(
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
el("h4", { className: "m01-easypick__ask", text: te("AskQty") }),
el("p", {
className: "m01-easypick__chosen",
children: [
el("span", { className: "m01-easypick__muted", text: te("Picked") }),
el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }),
],
}),
qty.root,
rough,
createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }),
);
body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.();
qty.input.focus();
void k;
};
if (kind) stepList(kind, false);
else stepKinds();
},
});
});
}
const labelOf = (k: Kind): string =>
te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]);
const descOf = (k: Kind): string =>
te(
(
{
person: "PersonDesc",
material: "MaterialDesc",
machine: "MachineDesc",
logic: "LogicDesc",
} as const
)[k],
);
/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */
function rowOf(c: Candidate, onPick: () => void): HTMLElement {
const b = el("button", {
className: "m01-easypick__row",
attrs: { type: "button", role: "option" },
children: [
el("span", {
className: "m01-easypick__name",
children: [
el("strong", { text: c.name }),
...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []),
...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []),
],
}),
el("span", { className: "m01-easypick__unit", text: c.unit }),
el("span", { className: "m01-easypick__price", text: c.priceText }),
],
});
b.addEventListener("click", onPick);
return b;
}
/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */
function moveFocus(body: HTMLElement, ev: KeyboardEvent): void {
const items = [...body.querySelectorAll<HTMLElement>("input[type=search], .m01-easypick__row")];
const at = items.indexOf(document.activeElement as HTMLElement);
if (at < 0) return;
const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)];
if (!next) return;
ev.preventDefault();
next.focus();
}
/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */
function subFilters(filter: Filter, load: () => Promise<void>): HTMLElement[] {
let subs: SubInfo[] = [];
const all = { value: "", text: te("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: te("SubLabel"),
onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: te("DetailLabel"),
onChange: (v) => ((filter.detail = v), void load()),
});
const fillDetail = (): void => {
const details = subs.find((s) => s.name === filter.sub)?.details ?? [];
detailBox.root.hidden = !details.length;
detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], "");
};
fillDetail();
void fetchSubs({ file: MAT_FILE })
.then((r) => {
subs = r;
subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub);
})
.catch(() => showToast(te("Failed"), "error"));
return [subBox.root, detailBox.root];
}
+155 -2
View File
@@ -1,6 +1,159 @@
.m01-easycalc__ready {
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
.ui-modal.m01-easycalc {
--modal-max: 90vh;
width: min(1280px, 96vw);
height: var(--modal-max);
font-size: var(--text-body);
}
.m01-easycalc .ui-modal__body {
flex: 1;
min-height: 0;
gap: var(--spacing-16);
}
.m01-easycalc__muted {
margin: 0;
padding: var(--spacing-16) 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easycalc__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
.m01-easycalc__start {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--spacing-24);
min-height: 0;
}
.m01-easycalc__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01-easycalc__start-col h4 {
margin: var(--spacing-8) 0 0;
}
.m01-easycalc__ask {
margin: 0;
font-size: var(--text-subheading);
}
.m01-easycalc__search .ui-input {
font-size: var(--text-body);
}
.m01-easycalc__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-easycalc__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-easycalc__item:hover:not(:disabled) {
border-color: var(--color-accent);
}
.m01-easycalc__item:disabled {
cursor: default;
opacity: 0.6;
}
.m01-easycalc__item-name {
flex: 1;
min-width: 0;
}
.m01-easycalc__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
/* 고치는 화면 — 위 띠 */
.m01-easycalc__strip {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding-bottom: var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m01-easycalc__strip .ui-field {
min-width: 120px;
}
.m01-easycalc__name {
flex: 1 1 240px;
}
.m01-easycalc__total {
font-size: var(--text-subheading);
font-weight: 700;
white-space: nowrap;
}
.m01-easycalc__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m01-easycalc__base {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-16);
}
.m01-easycalc__base p {
margin: 0;
}
.m01-easycalc__part {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-easycalc__part h4 {
margin: 0;
}
.m01-easycalc__cards {
flex: 1;
}
.m01-easycalc__sheet summary {
cursor: pointer;
font-weight: 600;
}
@@ -0,0 +1,141 @@
# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만)
⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜.
- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인.
- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗.
- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창).
## 1. 화면 자리
| 무엇 | 화면 위치 | 파일:줄 |
| --- | --- | --- |
| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` |
| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` |
| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` |
| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` |
| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` |
| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` |
- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림.
## 2. 대표 일 여섯 — 걸음
누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수.
| 일 | 길 | 누름 | 창 | 막힌 곳 |
| --- | --- | --- | --- | --- |
| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 |
| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 |
| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 |
| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 |
| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 |
| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 |
### ① 새 로직 — 「식으로 새로 만들기」 (실측)
1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4
2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2
3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17
4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12
5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3
6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1)
7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐.
8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함.
### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측)
- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」).
- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10).
- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄.
- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸.
- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더.
### ③ 수량 바꾸기
- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글.
- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김.
- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함.
### ④ 단가 자료(자재 · 노임) 바꾸기
- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3.
- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가.
- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐.
- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로.
- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움.
### ⑤ 줄 지우기
- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음.
- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음.
- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음.
### ⑥ 로직 지우기 · 되돌리기
- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소).
- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘.
- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘).
- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음).
- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐.
## 3. 처음 보는 사람이 모를 낱말
- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘
- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본
- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기
- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐
- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC
- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}`
- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」
- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표
- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임)
## 4. 화면에 안 보이는 규칙
1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로.
2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만.
3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`).
4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘.
5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과.
6. 변수 목록은 식 순서가 아니라 가나다 순.
7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦.
8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나.
9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함.
10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음.
11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤.
12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림.
13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함.
14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」.
## 5. 다섯 기능 — 하려던 것 · 어려운 것
- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함.
- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음.
- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」.
- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음.
- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004).
## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게)
| # | 흠 | 자리 | 사진 |
| --- | --- | --- | --- |
| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 |
| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — |
| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 |
| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 |
| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — |
| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 |
| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 |
## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫)
1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림.
2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임.
3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게.
4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로.
5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀.
6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직.
7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기.
8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로.
9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기.