- to-base 몸 환산? · 환산 없이 단위 성격 다름 = 후 None · 필요 ["환산"](막지 않음 · 화면 붉은 칸) - 화면 bind = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) + 블록 검산 입력 필요 · 줄 검산(빈 입력값)은 옵션을 못 읽었음 - 가져온 식 SK = M01 짝 없음 — 시트 M01바뀜 · 짝잃음 안 띄움(저장 M01판이 바탕 줄과 달라 늘 떴음) - 시험 test_793_3b 8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
439 lines
18 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|