feat(786-2): 불러오기 [수정] → 모달 「확인 — 제N호표」 옛 꼴 되살림 · 새 기능 「새」 배지

- 새 Hopyo_Check(모달 껍데기 · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 일위대가 ·
  [호표 제안 모두 확인](새) · [일위대가 생성] = 옮기기 길 이 호표만 · [구조물 조합으로] · [엑셀 보기])
- 새 Hopyo_CheckRows(로직 표 + 자재 표 = 구조물 조합 상세 꼴 · Labor_Rows moneyCell · PRICE_SLOTS ·
  Labor_MatMarks 빌려 씀 · 칸마다 엑셀 / 우리 단가 · 품 비교 · [고르기] · [원문 보기] · [옵션] · 추가 / 로직없음)
- 새 Hopyo_ToItem(구조물 조합 나무 add-item → 그 일위대가 문서에 SC 참조 한 줄 · 옮겨진 뒤만 켬)
- Hopyo_Rows: 시트 아래 줄 판 몸 걷음 → 줄 칸 부품 createRowKit(판정 · 고름 칩 · 블록 풀이 · 단위 환산 · [확인]) ·
  옮기기 한 번 runMove(오른쪽 [옮기기] · 모달 [일위대가 생성] 한 벌)
- Router: POST /hopyo/{키}/price 우리 금액 미리 셈(저장 없음 · Make.Move.pick · Lines.picks → Labor_Doc.calc 재사용)
- Sheet: [수정] = openHopyoCheck · 시트 아래 자리는 단가산출 블록 판(SheetSk) 몫으로 남김
- 안 살림: 맞음·별칭 일괄 체크 · 품셈으로 바꾸기 · 모두 고정으로(지운 서버 길 몫) · 사용안함(새 옮기기 길에 뺌 없음)
- 시험: 새 test_786_2_check_modal(우리 금액 예시 1 = 94,227 · 예시 3 입력 필요 → 0 · 쓰기 0 · 모달 · 새 배지 ·
  공용 import 만 · 날 button/input 0) · test_642_6 단언 = 줄 판 createJudgeRows → 모달 openHopyoCheck(까닭: 줄 판 몸 걷음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:26:37 +09:00
co-authored by Claude Opus 5.5
parent bcc570ff31
commit e91fd4398e
10 changed files with 1130 additions and 169 deletions
@@ -0,0 +1,351 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Check.ts
* 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) —
* 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶.
* · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 ·
* 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기]
* · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit`
* · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 ·
* 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만)
* · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음
* · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈
* · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`)
* · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로)
* · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { formatPrice } from "../common_util/common_util_money";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkJudge,
readJudgeBlock,
readJudgeOne,
type JudgeOne,
type JudgeRow,
type Pick,
} from "./M02_MasterTemplete_Hopyo_JudgeApi";
import {
checkTables,
newBadge,
NO_LOGIC,
type LinePrice,
type PriceLine,
} from "./M02_MasterTemplete_Hopyo_CheckRows";
import {
createRowKit,
runMove,
type JudgeState,
type SheetRole,
} from "./M02_MasterTemplete_Hopyo_Rows";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import {
bare,
confirmSuggested,
lineOf,
moveBody,
samePick,
setInput,
type DraftPick,
} from "./logic/M02_MasterTemplete_Hopyo_Picks";
import "./M02_MasterTemplete_Hopyo.css";
const enc = encodeURIComponent;
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const note = (text: string): HTMLElement => createText(text, "muted");
const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore
/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */
const priceJudge = (
target: HopyoScope,
file: HopyoScope,
key: string,
no: number,
줄: Record<string, Pick[]>,
): Promise<{ 줄: Record<string, PriceLine> }> =>
call(at(target, `/hopyo/${enc(key)}/price`), {
method: "POST",
body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore
});
export interface CheckOptions {
/** 실무자료 파일 층 */
file: HopyoScope;
key: string;
/** 사전조합 · 구조물 조합을 만들 층 */
target: HopyoScope;
state: JudgeState;
no: number;
/** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */
brief?: HopyoBrief;
canWrite: boolean;
/** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */
onSheet: (행: number, role: SheetRole) => void;
onHopyo: () => void;
/** 옮긴 뒤(목록 · 칠 다시 읽음) */
onMoved: () => void;
}
/** [수정] 모달 */
export function openHopyoCheck(opt: CheckOptions): { close: () => void } {
const readOnly = !opt.canWrite;
const no = opt.no;
const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore
let one: JudgeOne | null = null;
let shut: () => void = () => {};
let off: () => void = () => {};
let busy = false;
const leave = (): void => {
off();
shut();
};
// ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ──
const priced = new Map<number, { sig: string; seq: number; val: LinePrice }>();
let queued: Record<string, Pick[]> = {};
let seq = 0;
let timer = 0;
let pending = false;
function soon(): void {
if (pending) return;
pending = true;
window.setTimeout(() => {
pending = false;
if (root.isConnected) draw();
}, 0);
}
function flush(): void {
const ask = queued;
queued = {};
const my = ++seq;
for (const k of Object.keys(ask)) {
const slot = priced.get(Number(k));
if (slot) slot.seq = my;
}
priceJudge(opt.target, opt.file, opt.key, no, ask).then(
(got) => {
for (const k of Object.keys(ask)) {
const slot = priced.get(Number(k));
const line = got.줄[k];
if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") };
}
soon();
},
(e: unknown) => {
for (const k of Object.keys(ask)) {
const slot = priced.get(Number(k));
if (slot?.seq === my) slot.val = { fail: why(e) };
}
soon();
},
);
}
function price(row: JudgeRow): LinePrice | null {
const picks = kit.picksOf(row);
if (!picks.length) return null;
const 배 = lineOf(opt.state.get(), no, row.차례)?.배;
const body = picks.map((p) => bare(p, 배));
const sig = JSON.stringify(body);
const have = priced.get(row.차례);
if (have?.sig === sig) return have.val;
priced.set(row.차례, { sig, seq: -1, val: { wait: true } });
queued[String(row.차례)] = body;
window.clearTimeout(timer);
timer = window.setTimeout(flush, 250);
return { wait: true };
}
const kit = createRowKit({
file: opt.file,
key: opt.key,
state: opt.state,
no,
readOnly,
redraw: () => draw(),
onSheet: (r, role) => {
leave();
opt.onSheet(r, role);
},
});
/** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */
async function showSource(row: JudgeRow): Promise<void> {
const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore
const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : []));
const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
);
openModal({
title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`,
closeLabel: L("Common_Btn_Close"),
wide: true,
dialogClass: "m02-modal m02-hopyo__src",
mount: (body) => {
if (row.근거) body.append(note(row.근거));
const part = (title: string, list: string[], cut: (s: string) => string): void => {
body.append(el("h4", { text: title }));
if (!list.length) body.append(note(L("M02_CheckNone")));
for (const s of list) {
const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore
view.show(cut(s));
body.append(note(s), view.root);
}
};
part(L("M02_CheckSourceExcel"), excel, (s) => s);
part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — "));
},
});
}
/** 기초 하나 바꿔 넣음(같은 것 자리) */
const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void =>
kit.edit(
row,
kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)),
);
/** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */
function options(row: JudgeRow, p: DraftPick): void {
fetchLogicInputs([String(p.키)]).then(
(res) =>
openLaborInput({
title: `${p.키} ${p.이름}`.trim(),
logic: res.로직[0] ?? null,
values: p.입력값 as never,
memo: null,
readOnly,
done: (r) => {
let next = p;
const got = r.입력값 as Record<string, unknown>;
for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)]))
if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? "");
swap(row, p, next);
},
}),
(e: unknown) => showToast(why(e), "error"),
);
}
/** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */
function pick(row: JudgeRow): void {
openLogicPicker((logic) => {
const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore
checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then(
(res) => {
const c = res.고른[0];
const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] };
kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]);
},
(e: unknown) => showToast(why(e), "error"),
);
});
}
/** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */
function out(row: JudgeRow, value: string): void {
if (value === NO_LOGIC) {
kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore
return;
}
const d = opt.state.get();
const lines = { ...(d.호표[String(no)] ?? {}) };
delete lines[String(row.차례)];
opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } });
}
/** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */
async function make(): Promise<void> {
const all = moveBody(opt.state.get(), opt.file);
const mine = all.body.호표[String(no)] ?? {};
const n = Object.keys(mine).length;
if (!n) return void showToast(L("M02_JudgeMoveNone"), "info");
const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore
if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return;
busy = true;
draw();
const stuck = all.막힘.filter((x) => x.호표 === no);
const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck);
busy = false;
if (r) {
document.dispatchEvent(new Event(HOPYO_BUILT));
opt.onMoved();
await load(true);
} else draw();
}
function draw(): void {
if (!one) return;
const h = one;
const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length;
const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore
const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore
all.dataset.judgeAll = String(no);
const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore
go.dataset.hopyoMake = "";
const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore
item.dataset.hopyoToItem = "";
item.title = h.만든 ? "" : L("M02_CheckToItemHint");
const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore
sheet.dataset.hopyoSheet = "";
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar m02-judge__wrap",
children: [
el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`),
note(L("M02_CheckMoneyHint")),
note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore
...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []),
newBadge(),
all,
go,
item,
sheet,
],
});
const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore
const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out }; // prettier-ignore
root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore
}
async function load(quiet = false): Promise<void> {
const stop = quiet ? () => {} : showLoading(root);
try {
one = await readJudgeOne(opt.file, opt.key, no, opt.target);
draw();
} catch (e) {
if (!quiet) root.replaceChildren(loadingError(why(e), () => void load()));
} finally {
stop();
}
}
openModal({
title: L("M02_CheckTitle").replace("{n}", String(no)),
closeLabel: L("Common_Btn_Close"),
wide: true,
dialogClass: "m02-modal m02-hopyo__check-modal",
beforeClose: () => {
off();
return true;
},
mount: (body, close) => {
shut = close;
body.append(root);
},
});
off = opt.state.on(() => draw());
void load();
return { close: leave };
}