Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts
T
eomsangdonandClaude Opus 5.5 0ec931dcde fix(785-6): 화면 검증 흠 고침 — 장부 짝 단산 줄 · 기초 칩 갈음 · 단가산출 탭 [옮기기] · 확인 · 만든 줄 칠
- logic/Hopyo_Picks.ts: 장부 짝 줄 제안 = 고른 것 모두(기초 둘 · 입력값 그대로)
- Hopyo_Rows.ts: 단산 줄 = 판정과 상관없이 블록 풀이 칩(장부 짝 제안 있음 줄도) · 이름 없는 후보 = 키
- Hopyo_Block.ts: 기초 칩 = 키로 끄고 켬(장부 고른 것 → 블록 풀이 바인딩 · 입력 필요 다시 보임)
- Hopyo_SheetSk.ts: 단가산출 판 아래에도 [옮기기](같은 캐시) — 호표 목록 판이 숨는 탭
- logic/Hopyo_SheetMap.ts · SheetNav · Sheet: 판정 화면 확인함 · 만든 SC 호표 줄 = 초록(밖 줄 그대로) · 확인 바뀜 = 다시 칠
- 시험: test_642_6 장부 짝 · 줄 칠 · helper_642_6 칠 셈 · helper_778_5 Rows 대역

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:39:51 +09:00

353 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_Hopyo_Rows.ts
* 엑셀 불러오기 줄 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-3 · 2-4 · 2-6 · 분석 642-D 2-3 · 4) — 시트 아래 `[data-hopyo-lines]`.
* · 호표 하나 = 줄마다 1차 판정 칩(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 갈래 칩 · 근거 ·
* 후보 복수 고름(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · 기계 · 율 · 고정 — 한 줄에 여럿) ·
* 수량 · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [보기] = 엑셀 그 행 · [확인]
* · 단산 줄 = [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸) + 「단가산출 통째」 칩(단산N → SK)
* · 머리 [호표 제안 모두 확인](사용자 누름 · 다 풀린 제안만) · 아래 [옮기기](`createJudgeMove` · 오른쪽 판 아래)
* · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음
* · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import {
askConfirm,
createButton,
createInputField,
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 { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { formatNumber } from "../common_util/common_util_money";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block";
import {
checkJudge,
moveJudge,
readJudgeOne,
type JudgeOne,
type JudgeRow,
type MoveResult,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
bare,
confirmSuggested,
confirmable,
editLine,
lineOf,
linePicks,
missing,
moveBody,
putLine,
samePick,
togglePick,
type DraftPick,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit";
export { createJudgeState, type JudgeState };
const VERDICT: Record<string, BadgeTone> = {
"제안 있음": "success",
"2차 필요": "info",
"후보 여럿": "warning",
"후보 없음": "danger",
};
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v);
const bar = (kids: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "ui-toolbar m02-judge__wrap", children: kids });
export type SheetRole = "일위대가표" | "단가산출상세";
export interface JudgeRowsOptions {
host: HTMLElement;
/** 파일 층 */
file: HopyoScope;
key: string;
/** 만들 층 */
target: HopyoScope;
state: JudgeState;
no: number;
/** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */
onSheet?: (행: number, role: SheetRole) => void;
onClose?: () => void;
readOnly?: boolean;
}
const COLS: Column[] = [
{ label: L("M02_JudgeColRow"), width: "52px" },
{ label: L("M02_JudgeColName") },
{ label: L("M02_JudgeColVerdict"), width: "84px" },
{ label: L("M02_JudgeColKind") },
{ label: L("M02_JudgeColPick") },
{ label: L("M02_JudgeColQty"), align: "end" },
{ label: L("M02_JudgeColOk"), width: "72px" },
];
/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */
const wholeSk = (row: JudgeRow): DraftPick => ({
갈래: "단가산출",
키: `단산${row.번}`,
이름: row.이름 ?? `단산${row.번}`,
규격: row.규격 ?? "",
단위: row.단위 ?? "",
입력값: {},
출처: {},
몫: null,
});
/** 시트 아래 줄 판 — 호표 하나 */
export function createJudgeRows(opt: JudgeRowsOptions): { root: HTMLElement; destroy: () => void } {
const root = el("section", { className: "m02-judge", attrs: { "data-judge-rows": String(opt.no) } }); // prettier-ignore
root.append(createText(L("M02_JudgeLoading"), "hint"));
opt.host.replaceChildren(root);
let one: JudgeOne | null = null;
const open = new Set<number>();
const bodies = new Map<number, ReturnType<typeof solvedBody>>();
let alive = true;
const no = opt.no;
const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row);
const edit = (row: JudgeRow, list: DraftPick[]): void =>
opt.state.set(editLine(opt.state.get(), no, row, list));
function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const kids: HTMLElement[] = [];
if (row.번 !== undefined && row.풀이) {
const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); draw(); } }); // prettier-ignore
solve.root.dataset.judgeSolve = String(row.번);
const sk = wholeSk(row);
const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore
whole.root.dataset.judgeWhole = String(sk.키);
kids.push(solve.root, whole.root);
for (const p of picks.filter((x) => x.갈래 === "기초")) {
const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent");
b.dataset.judgePicked = String(p.키);
kids.push(b);
}
} else {
const seen = [...row.후보];
for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p);
for (const c of seen) {
const on = picks.some((p) => samePick(p, c));
const label = `${c.갈래} ${c.이름 || c.키 || ""}${c.규격 ? ` ${c.규격}` : ""}`;
const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore
chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`;
chip.root.title = c.키 ?? "";
kids.push(chip.root);
}
if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning"));
}
return bar(kids);
}
function qtyCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const line = lineOf(opt.state.get(), no, row.차례);
const 배 = line?.배 ?? row.수량.배;
const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore
const text = `${show(row.수량.엑셀)}${배 !== null && 배 !== 1 ? ` × ${show(배)}` : ""}`;
if (view.mode !== "input" || opt.readOnly) return bar([text]);
const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore
f.input.dataset.judgeTimes = String(row.차례);
f.input.addEventListener("change", () => {
const n = Number(f.input.value.replace(/,/g, ""));
const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n;
opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next }));
});
if (배 === null) f.setError(L("M02_JudgeNeed"));
return bar([text, L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]);
}
function okCell(row: JudgeRow, picks: DraftPick[]): HTMLElement {
const line = lineOf(opt.state.get(), no, row.차례);
const ok = createToggle({
kind: "chip",
label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"),
checked: !!line?.확인,
disabled: opt.readOnly,
onChange: (on) => {
if (on && !confirmable(picks)) {
ok.set(false);
const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand");
showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning");
return;
}
opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on }));
},
});
ok.root.dataset.judgeOk = String(row.차례);
return ok.root;
}
function rowCells(row: JudgeRow): Cell[] {
const picks = picksOf(row);
const jump =
row.행 !== null && opt.onSheet
? createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }) // prettier-ignore
: show(row.행);
if (jump instanceof HTMLElement) jump.dataset.judgeRow = String(row.행);
const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral");
v.dataset.judgeVerdict = row.판정;
const kinds = row.갈래.map((k) => {
const b = createBadge(k, "neutral");
b.dataset.judgeKind = k;
return b;
});
const name = [row.이름, row.규격, row.단위 ? `(${row.단위})` : ""].filter(Boolean).join(" ");
return [
jump,
name,
v,
bar([...kinds, row.근거]),
chips(row, picks),
qtyCell(row, picks),
okCell(row, picks),
];
}
/** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */
const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출";
const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p));
function solved(row: JudgeRow): HTMLElement {
let body = bodies.get(row.차례);
if (!body) {
body = solvedBody({
solved: row.풀이!,
picks: inner(row),
readOnly: opt.readOnly,
excel: row.단가,
onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined,
onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]),
onCheck: (list, signal) =>
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore
});
bodies.set(row.차례, body);
} else body.set(inner(row));
return body.root;
}
function draw(): void {
if (!one) return;
const all = createButton({
label: L("M02_JudgeConfirmAll"),
size: 24,
variant: "ghost",
disabled: opt.readOnly,
onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, one!.줄)),
});
all.dataset.judgeAll = String(no);
const actions: HTMLElement[] = [all];
if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore
const title = `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${one.이름}`;
const sub = [one.규격, one.단위, one.만든 ? `${L("M02_JudgeMade")} ${one.만든}` : ""].filter(Boolean).join(" · "); // prettier-ignore
const { root: table, tbody } = tableShell(COLS, { density: "data" });
for (const row of one.줄) {
const tr = tableRow(COLS, rowCells(row));
tr.dataset.judgeLine = String(row.차례);
tbody.append(tr);
if (row.풀이 && open.has(row.차례))
tbody.append(tableRow(COLS, [{ content: solved(row), colSpan: COLS.length }]));
}
root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, table);
}
const off = opt.state.on(draw);
void readJudgeOne(opt.file, opt.key, no, opt.target)
.then((got) => {
if (!alive) return;
one = got;
draw();
})
.catch((e: Error) => {
if (alive) root.replaceChildren(createText(e.message, "error"));
});
return {
root,
destroy(): void {
alive = false;
off();
root.remove();
},
};
}
export interface JudgeMoveOptions {
file: HopyoScope;
key: string;
target: HopyoScope;
state: JudgeState;
readOnly?: boolean;
onMoved?: (r: MoveResult) => void;
}
/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */
export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } {
const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore
btn.dataset.judgeMove = "";
const root = bar([btn]);
const count = (): void => {
const n = moveBody(opt.state.get(), opt.file).수;
btn.textContent = `${L("M02_JudgeMove")} (${n})`;
btn.disabled = !!opt.readOnly || n === 0;
};
async function go(): Promise<void> {
const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file);
if (!수) {
showToast(L("M02_JudgeMoveNone"), "info");
return;
}
btn.disabled = true;
try {
const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => {
// 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만)
if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e;
return moveJudge(opt.target, opt.key, { ...body, 덮음: true });
});
const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`));
let d = opt.state.get();
for (const [h, lines] of Object.entries(body.호표))
for (const at of Object.keys(lines))
if (!stuck.has(`${h}:${at}`)) {
const { [at]: _gone, ...rest } = d.호표[h] ?? {};
d = { ...d, 호표: { ...d.호표, [h]: rest } };
}
for (const b of Object.keys(body.블록))
if (!stuck.has(`b${b}:`)) {
const { [b]: _gone, ...rest } = d.블록;
d = { ...d, 블록: rest };
}
opt.state.set(d);
const msg = L("M02_JudgeMoved")
.replace("{a}", String(r.만듦.length))
.replace("{b}", String(r.단가산출.length))
.replace("{c}", String(r.막힘.length + 막힘.length));
showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success");
opt.onMoved?.(r);
} catch (e) {
showToast((e as Error).message, "error");
} finally {
count();
}
}
const off = opt.state.on(count);
count();
return {
root,
destroy(): void {
off();
root.remove();
},
};
}