- 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>
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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();
|
||
},
|
||
};
|
||
}
|