Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts
eomsangdonandClaude Opus 5.5 5a6cccd997 fix(793-3b): 우리 기초로 바꾸기 — 옵션 = 블록 풀이 후보 바인딩 · 단위 성격 다르면 환산 계수 입력 필요 · 가져온 식 시트 M01 바뀜 표지 걷음
- to-base 몸 환산? · 환산 없이 단위 성격 다름 = 후 None · 필요 ["환산"](막지 않음 · 화면 붉은 칸)
- 화면 bind = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) + 블록 검산 입력 필요 · 줄 검산(빈 입력값)은 옵션을 못 읽었음
- 가져온 식 SK = M01 짝 없음 — 시트 M01바뀜 · 짝잃음 안 띄움(저장 M01판이 바탕 줄과 달라 늘 떴음)
- 시험 test_793_3b 8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:26:07 +09:00

439 lines
18 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Block.ts
* 엑셀 불러오기 단산 블록 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-5 · 2-6) — 줄 판 단산 줄 펼침 · 단가산출 탭 줄 판 한 몸.
* · 블록 머리 = 블록 판정(기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 n) · 근거 표지 · 금액 참고(우리 · 엑셀)
* · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 ·
* 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액
* · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것
* · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기)
* · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버
* 판정 · 금액은 서버 · 여기는 보이기 · 고르기만.
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import type { HopyoScope, Money } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkJudge,
readJudgeBlock,
type BaseCand,
type CandMoney,
type CheckOne,
type CheckResult,
type ExtraLine,
type PairLine,
type PickKind,
type Section,
type Solved,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
bare,
basePick,
blockPicks,
confirmable,
draftKey,
emptyDraft,
missing,
parseDraft,
putBlock,
samePick,
setInput,
togglePick,
type DraftPick,
type JudgeDraft,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import "./M02_MasterTemplete_Hopyo_Judge.css";
/** 고름 캐시 손잡이 — 파일 하나 · sessionStorage(포트별) · 바뀌면 알림 */
export function createJudgeState(file: HopyoScope, key: string) {
const k = draftKey(file.layer, file.owner, key);
let d: JudgeDraft = parseDraft(sessionStorage.getItem(k));
const subs = new Set<() => void>();
return {
file,
key,
get: (): JudgeDraft => d,
set(next: JudgeDraft): void {
d = next;
sessionStorage.setItem(k, JSON.stringify(next));
subs.forEach((f) => f());
},
clear(): void {
sessionStorage.removeItem(k);
d = emptyDraft();
subs.forEach((f) => f());
},
on(f: () => void): () => void {
subs.add(f);
return () => subs.delete(f);
},
};
}
export type JudgeState = ReturnType<typeof createJudgeState>;
const TONE: Record<string, BadgeTone> = {
"기초로 풂": "success",
"기초+덧": "success",
"줄 조합": "info",
"고를 것": "warning",
"기초 풂": "success",
"후보 여럿": "warning",
"기초 없음": "danger",
"품 같음": "success",
"품 다름": "warning",
"입력 필요": "danger",
엑셀만: "info",
우리만: "neutral",
확인: "success",
};
const badge = (text: string, at?: string): HTMLElement => {
const b = createBadge(text, TONE[text] ?? "neutral");
if (at) b.dataset[at] = text;
return b;
};
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "—" : formatPrice(Math.floor(v));
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
const bar = (kids: HTMLElement[]): HTMLElement =>
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
/** 금액 한 줄 — 노무 · 재료 · 경비 · 계(없으면 까닭) */
export function moneyText(m: CandMoney | Partial<Money> | null | undefined): string {
if (!m) return "—";
if (m.계 === null && "까닭" in m) return String(m.까닭);
const x = m as Partial<Money>;
return `${won(x.노무비)} · ${won(x.재료비)} · ${won(x.경비)} = ${won(x.계)}`;
}
export interface SolvedBodyOptions {
solved: Solved;
picks: DraftPick[];
/** 고름 · 입력 바뀜 — 부르는 쪽이 캐시에 넣음 */
onPicks: (list: DraftPick[]) => void;
/** 서버 검산(없으면 단추 안 보임) */
onCheck?: (list: DraftPick[], signal: AbortSignal) => Promise<CheckResult>;
/** 엑셀 보기 단가산출 상세 그 행 */
onSheet?: (행: number) => void;
excel?: Partial<Money> | null;
readOnly?: boolean;
}
/** 입력값 표 — 엔진 입력값 + 입력 필요 + 엇갈림 칸 */
function inputTable(
opt: SolvedBodyOptions,
cand: BaseCand,
p: DraftPick,
put: (p: DraftPick) => void,
): HTMLElement {
const b = cand.바인딩;
const names = [...new Set([...Object.keys(p.입력값), ...b.입력필요, ...Object.keys(b.엇갈림)])];
const need = new Set(missing(p));
const rows: Cell[][] = names.map((name) => {
const src = p.출처[name]?.길;
const tags: HTMLElement[] = [];
if (src) {
const tag = createBadge(src === "사용자" ? L("M02_JudgeUser") : src === "기본" ? L("M02_JudgeDefault") : src, src.startsWith("B") ? "accent" : "info"); // prettier-ignore
tag.dataset.judgeSrc = src;
if (p.출처[name]?.글) tag.title = String(p.출처[name]?.글);
tags.push(tag);
}
if (need.has(name)) tags.push(badge(L("M02_JudgeNeed"), "judgeNeed"));
const split = b.엇갈림[name];
if (split) tags.push(badge(L("M02_JudgeConflict"), "judgeConflict"));
let field: HTMLElement;
if (split && !opt.readOnly) {
const picked = split.findIndex((s) => s.값 === p.입력값[name]);
const sel = createSelectField({
options: [{ value: "", text: "—" }].concat(split.map((s, i) => ({ value: String(i), text: `${show(s.값)} (${s.길})` }))), // prettier-ignore
value: picked < 0 ? "" : String(picked),
compact: true,
size: 24,
ariaLabel: name,
onChange: (v) => put(v === "" ? setInput(p, name, "") : setInput(p, name, split[+v].값, split[+v].길)), // prettier-ignore
});
sel.select.dataset.judgeInput = name;
field = sel.root;
} else {
const f = createInputField({ compact: true, size: 24, value: show(p.입력값[name]) });
f.input.dataset.judgeInput = name;
f.input.readOnly = !!opt.readOnly;
f.input.addEventListener("change", () => put(setInput(p, name, f.input.value)));
if (need.has(name)) f.setError(L("M02_JudgeNeed"));
field = f.root;
}
const dflt = b.기본[name];
if (need.has(name) && dflt !== undefined && dflt !== null && !opt.readOnly) {
const use = createButton({ label: `${L("M02_JudgeDefault")} ${show(dflt)}`, size: 24, variant: "link", onClick: () => put(setInput(p, name, dflt, "기본")) }); // prettier-ignore
use.dataset.judgeDefault = name;
tags.push(use);
}
return [name, field, bar(tags)];
});
return createTable(
[{ label: L("M02_JudgeColInput"), width: "9em" }, { label: L("M02_JudgeColValue"), width: "12em" }, { label: L("M02_JudgeColSource") }], // prettier-ignore
rows,
{ density: "data" },
);
}
/** 줄 대조 표 */
function pairTable(opt: SolvedBodyOptions, lines: PairLine[]): HTMLElement {
const rows: Cell[][] = lines.map((x) => {
const at = x.엑셀?.행;
const jump =
at && opt.onSheet
? createButton({ label: L("M02_JudgeSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(at) }) // prettier-ignore
: "";
return [
x.엑셀?.묶음 ?? x.우리?.종류 ?? "",
x.엑셀 ? `${x.엑셀.이름} ${show(x.엑셀.품)}` : "—",
x.우리 ? `${x.우리.이름} ${show(x.우리.품)}${x.우리.까닭 ? ` · ${x.우리.까닭}` : ""}` : "—",
bar([badge(x.판정, "judgePair"), ...(x.표지 ? [createBadge(x.표지, "neutral")] : [])]),
jump,
];
});
return createTable(
[
{ label: L("M02_JudgeColGroup"), width: "48px" },
{ label: L("M02_JudgeColExcel") },
{ label: L("M02_JudgeColOurs") },
{ label: L("M02_JudgeColPair") },
{ label: L("M02_JudgeSheet"), width: "52px" },
],
rows,
{ density: "data" },
);
}
/** 덧 줄 후보 → 고른 것 */
function extraPick(x: ExtraLine, c: NonNullable<ExtraLine["후보"]>[number]): DraftPick {
const 갈래: PickKind = c.자재 ? "재료" : c.노임 ? "인력" : "기계";
const 키 = c.자재 ?? c.노임 ?? c.키 ?? null;
return { 갈래, 키, 이름: c.이름 ?? x.이름, 규격: c.규격 ?? "", 단위: c.단위 ?? "", 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
}
/** 블록 풀이 몸 — 상태(고름 · 검산 결과)를 쥐고 바뀌면 다시 그림 */
export function solvedBody(opt: SolvedBodyOptions): {
root: HTMLElement;
set: (picks: DraftPick[]) => void;
} {
const root = el("div", {
className: "m02-judge",
attrs: { "data-judge-solved": String(opt.solved.번) },
});
let picks = opt.picks;
let checked: Record<string, CheckOne> = {};
let ask: AbortController | null = null;
const put = (list: DraftPick[]): void => {
picks = list;
checked = {};
opt.onPicks(list);
draw();
};
const swap = (old: DraftPick, next: DraftPick): void =>
put(picks.map((x) => (samePick(x, old) ? next : x)));
async function check(): Promise<void> {
if (!opt.onCheck) return;
ask?.abort();
ask = new AbortController();
try {
const got = await opt.onCheck(picks, ask.signal);
checked = Object.fromEntries(got.고른.map((c) => [String(c.키), c]));
draw();
} catch (e) {
if ((e as Error).name !== "AbortError") showToast((e as Error).message, "error");
}
}
function candBox(cand: BaseCand): HTMLElement | null {
const p = picks.find((x) => x.갈래 === "기초" && x.키 === cand.키);
if (!p) return null;
const got = checked[cand.키];
const box = el("div", { className: "m02-judge__cand", attrs: { "data-judge-cand": cand.키 } });
const head = [createText(cand.이름, "muted"), badge(got?.판정 ?? cand.판정, "judgeVerdict")];
box.append(bar(head));
box.append(inputTable(opt, cand, p, (n) => swap(p, n)));
if (cand.바인딩.안씀.length)
box.append(createText(`${L("M02_JudgeUnused")} — ${cand.바인딩.안씀.join(" · ")}`, "hint"));
box.append(pairTable(opt, got?.줄 ?? cand.줄));
const money = el("span", { text: `${L("M02_JudgeOursMoney")} ${moneyText(got?.금액 ?? cand.금액)}` }); // prettier-ignore
money.dataset.judgeMoney = cand.키;
box.append(bar([money]));
return box;
}
function secBox(sec: Section, i: number): HTMLElement {
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-sec": String(i) } });
const title = `${L("M02_JudgeSection")} ${sec.번 ?? i + 1}. ${sec.제목}`;
const chips: HTMLElement[] = sec.후보.map((c) => {
const on = picks.some((x) => x.갈래 === "기초" && x.키 === c.키);
const chip = createToggle({ kind: "chip", label: `${c.키} ${c.이름}`, checked: on, disabled: opt.readOnly, onChange: () => put(on ? picks.filter((x) => !(x.갈래 === "기초" && x.키 === c.키)) : [...picks, basePick(c, sec)]) }); // prettier-ignore
chip.root.dataset.judgeBase = c.키;
return chip.root;
});
if (!chips.length) chips.push(createText(L("M02_JudgeNoBase"), "warning"));
box.append(bar([createText(title, "muted", 14), badge(sec.판정, "judgeSec")]), bar(chips));
for (const c of sec.후보) {
const cb = candBox(c);
if (cb) box.append(cb);
}
return box;
}
function extraBox(): HTMLElement | null {
if (!opt.solved.덧.length) return null;
const rows: Cell[][] = opt.solved.덧.map((x) => {
const chips = (x.후보 ?? []).slice(0, 4).map((c) => {
const p = extraPick(x, c);
const on = picks.some((y) => samePick(y, p));
const chip = createToggle({ kind: "chip", label: `${p.이름} ${p.규격 ?? ""}`.trim(), checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, p)) }); // prettier-ignore
chip.root.dataset.judgeExtra = String(p.키);
return chip.root;
});
return [x.절, x.이름, x.방법 ?? "", bar(chips)];
});
const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-extra": "" } });
box.append(createText(L("M02_JudgeExtra"), "muted", 14));
box.append(createTable([{ label: L("M02_JudgeSection"), width: "8em" }, { label: L("M02_JudgeColExcel") }, { label: "" }, { label: L("M02_JudgeColPick") }], rows, { density: "data" })); // prettier-ignore
return box;
}
function draw(): void {
const s = opt.solved;
const verdict = s.판정 === "고를 것" ? L("M02_JudgePickN").replace("{n}", String(s.고를수)) : s.판정; // prettier-ignore
const v = createBadge(verdict, TONE[s.판정] ?? "neutral");
v.dataset.judgeBlockVerdict = s.판정;
const ours = picks.reduce(
(a, p) => {
const c = s.절.flatMap((x) => x.후보).find((x) => x.키 === p.키);
const m = (checked[String(p.키)]?.금액 ?? c?.금액) as Partial<Money> | undefined;
return a === null || !m || m.계 === null || m.계 === undefined ? null : a + m.계;
},
picks.length ? 0 : (null as number | null),
);
const head: HTMLElement[] = [v, el("span", { text: `${L("M02_JudgeOursMoney")} ${won(ours)}`, attrs: { "data-judge-ours": "" } })]; // prettier-ignore
if (opt.excel) head.push(el("span", { text: `${L("M02_JudgeExcelMoney")} ${moneyText(opt.excel)}`, attrs: { "data-judge-excel": "" } })); // prettier-ignore
if (opt.onCheck) {
const go = createButton({ label: L("M02_JudgeCheck"), size: 24, variant: "ghost", disabled: !picks.length, onClick: () => void check() }); // prettier-ignore
go.dataset.judgeCheck = "";
head.push(go);
}
root.replaceChildren(bar(head), createText(L("M02_JudgeAmountNote"), "hint"));
s.절.forEach((sec, i) => root.append(secBox(sec, i)));
const extra = extraBox();
if (extra) root.append(extra);
}
draw();
return {
root,
set(list: DraftPick[]): void {
picks = list;
draw();
},
};
}
export interface JudgeBlockOptions {
host: HTMLElement;
/** 파일 층 */
file: HopyoScope;
key: string;
/** 만들 층 */
target: HopyoScope;
state: JudgeState;
no: number;
onSheet?: (행: number) => void;
onClose?: () => void;
readOnly?: boolean;
}
/** 단가산출 탭 줄 판 — 블록 하나 · [확인] = 그 블록 SK 로 옮길 것 */
export function createJudgeBlock(opt: JudgeBlockOptions): {
root: HTMLElement;
destroy: () => void;
} {
const root = el("section", {
className: "m02-judge",
attrs: { "data-judge-block": String(opt.no) },
});
root.append(createText(L("M02_JudgeLoading"), "hint"));
opt.host.replaceChildren(root);
let alive = true;
void readJudgeBlock(opt.file, opt.key, opt.no, opt.target)
.then((blk) => {
if (!alive) return;
const line = () => opt.state.get().블록[String(blk.번)];
const ok = createToggle({
kind: "chip",
label: L("M02_JudgeConfirm"),
checked: !!line()?.확인,
disabled: opt.readOnly,
onChange: (on) => {
const list = blockPicks(opt.state.get(), blk);
if (on && !confirmable(list)) {
ok.set(false);
showToast(`${L("M02_JudgeBlocked")} — ${list.flatMap(missing).join(" · ")}`, "warning");
return;
}
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: on }));
},
});
ok.root.dataset.judgeOk = "block";
const actions: HTMLElement[] = [ok.root];
const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore
open.dataset.judgeImported = String(blk.번);
const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함)
fresh.dataset.checkNew = "";
actions.push(open, fresh);
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`;
const sub = [
blk.근거?.표지,
blk.근거?.출처.map((x) => x.join(" ")).join(" · "),
blk.부름.length ? `${L("M02_JudgeCalls")} ${blk.부름.map((n) => L("M02_JudgeHopyo").replace("{n}", String(n))).join(" · ")}` : "", // prettier-ignore
blk.만든 ? `${L("M02_JudgeMade")} ${blk.만든}` : "",
].filter(Boolean).join(" · "); // prettier-ignore
const body = solvedBody({
solved: blk,
picks: blockPicks(opt.state.get(), blk),
excel: blk.금액,
readOnly: opt.readOnly,
onSheet: opt.onSheet,
onPicks: (list) => {
opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: false }));
ok.set(false);
},
onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
});
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, body.root);
})
.catch((e: Error) => {
if (alive) root.replaceChildren(createText(e.message, "error"));
});
return {
root,
destroy(): void {
alive = false;
root.remove();
},
};
}