소요량·계수의 쓰는 로직 · 조합 화면 로직 이름 · 패널 제목 세 자리를 모두 새 컨테이너(UI_LogicLab)로 돌림 · 옛 컨테이너(UI_Logic_Page)는 old_code/M01_MasterData/ 로 옮김(지우지 않음 · import 경로만 고침 · 통째 복사 아님) · 정본 GC·GF 를 화면에서 바로 고쳐 저장 · 정본 파일에 빈 줄 더하기 두 길은 사용자 확정으로 버림. 분석 = resources/master_data/ref/_분석_로직컨테이너_통합.md. typecheck 통과 · M01 시험 107 통과 · ORCA — 패널에 「단가산출 로직」 하나만 · 소요량 표 쓰는 로직 키(GF000002 · GF000051) 클릭 각각 그 로직이 새 컨테이너에서 열림 · 조합 화면 로직 이름도 같은 컨테이너로 이어짐. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
273 lines
9.4 KiB
TypeScript
273 lines
9.4 KiB
TypeScript
/* =============================================================================
|
|
* 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),
|
|
],
|
|
});
|
|
}
|