- 새 공용 단가산출 상세 `M01_MasterData_UI_LogicLab_Ho.ts` — 하위 박스 = 서버 묶음(인력 · 재료 · 경비(장비) · 하위) · 장비 · 하위 로직 = 한 줄 + 펼침(부른 로직 시간당 줄 · 몫 줄 · 다시 셈해도 펼친 채) · 「계산식」 열 = 값 든 식 + 알약 + 그 줄 입력 칩 · 이름 식 = 풍선 · 박스 제목 아래 옵션 고르기 · 어느 박스도 안 쓰는 입력 = 공통 옵션 · 맨 아래 비목 소계 셋 + 계 - 새 `M01_MasterData_UI_LogicLab_Cover.ts` — 줄 모달 「그 자리 고침」(수량 · 단가 · 로직 줄은 노 · 재 · 경) · M01 = 시험 값만(로직 줄 객체 열쇠 메모리 · 시험 계산 줄 사본에 손 단가 · 값묶음 · 수량 덮음 · 정본 · 요소 · 캐시 안 바뀜) · 고침 꼴 = M02 덮개 칸(724 고침 고리 `HoEdit`) - `LogicLab_Detail.ts` 단가산출 상세 = 공용 부품(호표 묶음 표 코드 옮김 · 551 → 252 줄) · `LogicLab.ts` 시험 계산 줄 = trial.apply · dirty 에 시험 고침 · onTrial(줄 수 627 그대로) - `calc_sheet.ts` varRow 내보냄 · 값 없으면 빈 첫 칸 · `LogicLab_Formula.ts` inputChip 내보냄 · `LogicLab_Modal.ts` 줄 모달 edit 칸 - 보기 고르기 넷 그대로(지우기는 사용자 확인 뒤) - 지킴 시험 test_722_ho_detail(박스 묶기 · 고침 덮기 순수 함수 · 캐시 밖 · 부품 상자) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M01_MasterData_UI_LogicLab_Detail.ts
|
|
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
|
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
|
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
|
|
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
|
* ========================================================================== */
|
|
|
|
import { el } from "@ui/ui_template_elements";
|
|
import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
|
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
|
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
|
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
|
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
|
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
|
import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
|
|
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
|
|
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
|
import "./M01_MasterData_UI_LogicLab_Style.css";
|
|
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
|
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
|
|
|
export { trial };
|
|
|
|
export interface DetailContext extends HoContext {
|
|
file: string;
|
|
reasons: string[];
|
|
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
|
ownHost: HTMLElement;
|
|
onChange: () => void;
|
|
/** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */
|
|
calcHost: HTMLElement;
|
|
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
|
|
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
|
|
flowHost?: HTMLElement;
|
|
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
|
|
admin?: boolean;
|
|
/** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */
|
|
onTrial?: () => void;
|
|
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
|
cover?: { ho: HTMLElement; est: HTMLElement };
|
|
}
|
|
|
|
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
|
el("section", {
|
|
className: "m01-logic__section m01lab__box",
|
|
children: [
|
|
el("div", {
|
|
className: "m01-logic__section-head",
|
|
children: [
|
|
el("h3", { text: title }),
|
|
...(hint ? [el("span", { className: "ui-text ui-text--muted", text: hint })] : []),
|
|
],
|
|
}),
|
|
...body,
|
|
],
|
|
});
|
|
|
|
function infoBox(ctx: DetailContext): HTMLElement[] {
|
|
const row = ctx.row;
|
|
const own = isOwnLogic(row);
|
|
const field = (label: string, key: "이름" | "원문번호" | "결과단위" | "출처"): HTMLElement => {
|
|
if (!own) return cell(label, row[key] ?? "");
|
|
const box = el("input", {
|
|
className: "ui-input ui-input--sm m01-logic__input",
|
|
attrs: { "data-own": key },
|
|
});
|
|
box.value = row[key] ?? "";
|
|
box.addEventListener("input", () => {
|
|
row[key] = box.value;
|
|
ctx.onChange();
|
|
});
|
|
return el("label", {
|
|
className: "m01lab__cell",
|
|
children: [el("span", { className: "ui-text ui-text--muted", text: label }), box],
|
|
});
|
|
};
|
|
const cell = (label: string, value: string): HTMLElement =>
|
|
el("div", {
|
|
className: "m01lab__cell",
|
|
children: [
|
|
el("span", { className: "ui-text ui-text--muted", text: label }),
|
|
el("span", { text: value || "—" }),
|
|
],
|
|
});
|
|
const note = el("textarea", {
|
|
className: "ui-input ui-input--sm m01-logic__input m01-logic__note",
|
|
});
|
|
note.value = row.비고 ?? "";
|
|
note.addEventListener("input", () => {
|
|
if (note.value.trim()) row.비고 = note.value;
|
|
else delete row.비고;
|
|
ctx.onChange();
|
|
});
|
|
const reasons = ctx.reasons.length
|
|
? [
|
|
el("div", {
|
|
className: "ui-banner ui-banner--danger m01-logic__reasons",
|
|
children: [
|
|
el("strong", { text: tx("Head_Blocked") }),
|
|
...ctx.reasons.map((r) => el("div", { text: safeReason(r) })),
|
|
],
|
|
}),
|
|
]
|
|
: [];
|
|
const grid = el("div", {
|
|
className: "m01lab__info",
|
|
children: [
|
|
cell(tx("Head_File"), ctx.file),
|
|
cell(tx("Head_Key"), row.키),
|
|
field(tx("Head_Number"), "원문번호"),
|
|
field(tx("Head_Name"), "이름"),
|
|
field(tx("Head_Unit"), "결과단위"),
|
|
field(tx("Head_Source"), "출처"),
|
|
cell(tx("Head_Owner"), row.소유 ?? ""),
|
|
el("label", {
|
|
className: "m01lab__cell m01lab__cell--wide",
|
|
children: [
|
|
el("span", { className: "ui-text ui-text--muted", text: tx("Head_Note") }),
|
|
note,
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
// 늘 맨 위 접힌 한 줄 — 펼침은 이 로직 안에서 기억(다시 그려도) · 막힘 까닭은 접어도 보임
|
|
const head = [row.키, row.원문번호, row.이름, row.결과단위].filter(Boolean).join(" · ");
|
|
const fold = el("details", {
|
|
className: "m01lab__box m01lab__infofold",
|
|
attrs: ctx.ownHost.dataset.info ? { open: "" } : {},
|
|
children: [
|
|
el("summary", {
|
|
children: [
|
|
el("strong", { text: tl("Info_Title") }),
|
|
el("span", { className: "ui-text ui-text--muted", text: head }),
|
|
],
|
|
}),
|
|
el("p", {
|
|
className: "ui-text ui-text--muted",
|
|
text: tl(own ? "Info_Own" : ctx.admin ? "Info_Base" : "Info_ReadOnly"),
|
|
}),
|
|
grid,
|
|
],
|
|
});
|
|
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.info = fold.open ? "1" : ""));
|
|
return [fold, ...reasons];
|
|
}
|
|
|
|
/** 자체 로직(키 GX…) — 정본은 아님 */
|
|
export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX");
|
|
|
|
/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */
|
|
function ownEditor(ctx: DetailContext): HTMLElement {
|
|
if (!ctx.ownHost.childElementCount)
|
|
buildEditor(ctx.ownHost, {
|
|
row: ctx.row,
|
|
file: ctx.file,
|
|
isNew: false,
|
|
files: [],
|
|
reasons: [],
|
|
prices: ctx.prices,
|
|
lines: ctx.lines,
|
|
onChange: ctx.onChange,
|
|
onFile: () => undefined,
|
|
onPick: openPicker,
|
|
});
|
|
return el("details", {
|
|
className: "m01lab__edit",
|
|
attrs: { open: "" },
|
|
children: [el("summary", { text: tl("Edit_Title") }), ctx.ownHost],
|
|
});
|
|
}
|
|
|
|
/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */
|
|
function trialEdit(ctx: DetailContext): HoEdit | undefined {
|
|
const redraw = ctx.onTrial;
|
|
if (!redraw) return undefined;
|
|
const covers = trial.of(ctx.row);
|
|
return {
|
|
covers,
|
|
scope: "m01",
|
|
set: (idx, cover) => {
|
|
if (cover) covers[idx] = cover;
|
|
else delete covers[idx];
|
|
redraw();
|
|
ctx.calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
|
},
|
|
};
|
|
}
|
|
|
|
function hoBox(ctx: DetailContext): HTMLElement {
|
|
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
|
if (!money) {
|
|
return block(
|
|
section(tl("Ho_Title"), [
|
|
el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }),
|
|
el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }),
|
|
]),
|
|
"ho",
|
|
);
|
|
}
|
|
const body = buildHo({ ...ctx, edit: trialEdit(ctx) });
|
|
const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠
|
|
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임)
|
|
const fold = el("details", {
|
|
className: "m01lab__fold",
|
|
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
|
|
children: [el("summary", { text: tl("Ho_Fold") }), ...body],
|
|
});
|
|
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
|
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
|
|
const calculated = ctx.lines !== null;
|
|
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
|
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
|
|
}
|
|
|
|
/** 상세 화면 넷을 `host` 에 쌓음 */
|
|
export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
|
const formula = el("div", { className: "m01lab__formula" });
|
|
const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput };
|
|
buildFormula(formula, { text: ctx.text, ...fx });
|
|
// 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림
|
|
const active = document.activeElement;
|
|
const kept =
|
|
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
|
? { node: active, from: active.selectionStart, to: active.selectionEnd }
|
|
: null;
|
|
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
|
|
host.replaceChildren(
|
|
...infoBox(ctx),
|
|
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
|
block(
|
|
section(tl("View_Est"), [
|
|
ctx.cover?.est ?? buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx }),
|
|
]),
|
|
"est",
|
|
),
|
|
...(ctx.flowHost && !ctx.cover
|
|
? [block(nowInputs(ctx.values), "ho"), ctx.flowHost]
|
|
: [
|
|
ctx.cover
|
|
? block(section(tl("Ho_Title"), [nowInputs(ctx.values), ctx.cover.ho]), "ho")
|
|
: hoBox(ctx),
|
|
block(section(tl("Formula_Title"), [formula]), "formula"),
|
|
]),
|
|
);
|
|
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
|
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
|
|
if (kept && back && (own || ctx.ownHost.contains(back))) {
|
|
back.focus();
|
|
if (kept.from !== null) back.setSelectionRange(kept.from, kept.to);
|
|
}
|
|
}
|