refactor(785-7): 옛 맞춤 화면 TS 지움 · 옛 [저장] · [모두 고정] 단추 걷음 · 390 줄 판 높이

- 지움(git rm) 15 파일 3,318 줄: Hopyo_Check · CheckRows · Choose · WizardSk · SkApi · SkLines · logic/SkPick · logic/Draft ·
  Left · Lost · Pick · SheetMake · Kids · ToItem · Compare(모두 옛 확인 창 · 마법사 단가산출 · 안 이은 줄 길 몫 · 부르는 곳 0)
- 단추 판단: 엑셀 보기 [저장](체크 호표) · [모두 고정으로 들여오기] 화면 단추 걷음 → 만들기 = [옮기기] 하나 ·
  모두 고정 서버 길(Builder · build_all)은 B09 · 붙여넣기가 써서 남김 · 목록 체크 칸 · 모달 껍데기(openHopyoSheet · raise · lower) 걷음
- Import: 「안 이은 줄」 띠 · [안 이은 줄 고르기] · 남은 줄 셈 걷음 · [끝내기] = 물음(확인하고 안 옮긴 수 = 판정 고름 캐시 moveBody)
- Api: 옛 맞춤 타입 · 길(readHopyoOne · guessHopyo · buildHopyo · buildHopyoAll · leftHopyo · listHopyoPicks · hopyoSiblings ·
  buildHopyoItem …) 걷음 · POLL_MS · HOPYO_BUILT 옮겨 옴 · 목록 꼴 = 1차 판정 줄상태(Judge.view)
- Labor_Pick: withHopyo(실무 호표 덧줄) → withSangeun(찾아 넣기 창 표시만) · Detail · Table 부름 바꿈
- Ledger: unitField 옮겨 옴 · SheetNav: 옛 확인 캐시 칠 걷음 · UI_Page: [구조물 조합으로] 듣기 걷음
- CSS: 옛 확인 창 · 고르기 창 규칙 34 걷음(481 → 287 줄) · 390 폭 = 줄 판 열리면 위 칸 넓힘 · 줄 판 높이 바닥 16rem

시험 정리(까닭 = 지운 화면 파일만 읽음):
- 지움: test_729_3_hopyo_left_screen · test_746_2_hopyo_compare_screen · test_778_5_sk_pick_screen · helper_729_3 · helper_m02_hopyo_check · pick · seam
- test_m02_hopyo_screen = 좌측 판 · 끝내기 둘만 · 627 · helper_778_5 = SheetSk 판 바꿈만 · 785_4 · 721 · 717_4 · 720 · 739_1 = 지금 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:32:17 +09:00
co-authored by Claude Opus 5.5
parent 48848bb287
commit cf4e2a0339
45 changed files with 126 additions and 5791 deletions
@@ -1,662 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Check.ts
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄.
* · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
* · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`)
* · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만)
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* · 705-3 — 체크 안 한 줄 = 고정 줄(엑셀 숫자 그대로) · 만든 사전조합이 있으면 첫 체크 = 그 품셈 줄(`만든.품셈`) ·
* [품셈으로 바꾸기 (n)] = 맞음 · 별칭 줄 체크 + 생성 · [모두 고정으로] = 체크 모두 끔 + 생성(되돌리기) ·
* 생성 물음에 전후 금액(품셈 = 우리 미리 셈 · 고정 = 엑셀) · 품 다른 줄 수.
* · 704-4 — [구조물 조합으로] = 같은 몸으로 구조물 조합 항목 바로(`Hopyo_ToItem` · 규격 묶음 제안)
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import {
guessHopyo,
readHopyoOne,
type BuildLine,
type BuildTarget,
type HopyoBuilt,
type HopyoLine,
type HopyoMatch,
type HopyoOne,
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
checkTables,
type LinePrice,
type RowsContext,
} from "./M02_MasterTemplete_Hopyo_CheckRows";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem";
import { openLeftOf } from "./M02_MasterTemplete_Hopyo_Left";
import { formatNumber, formatPrice } from "../common_util/common_util_money";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
/** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */
export const HOPYO_BUILT = "m02-hopyo-built";
/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */
const DRAFT_COLUMN = "hopyo-draft";
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
export interface CheckDraft {
체크: Record<number, boolean>;
대상: Record<number, HopyoPart[]>;
뺌: Record<number, string>;
품: Record<number, HopyoMatch["품"]>;
통째?: boolean;
}
const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} });
export const draftKey = (scope: HopyoScope, key: string, no: number): string =>
`m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`;
export function loadDraft(k: string): CheckDraft {
try {
const raw = sessionStorage.getItem(k);
return raw ? { ...empty(), ...(JSON.parse(raw) as Partial<CheckDraft>) } : empty();
} catch {
return empty();
}
}
/** 확인 창을 열어 만진 적 있음(캐시 있음) */
export function hasDraft(k: string): boolean {
try {
return sessionStorage.getItem(k) !== null;
} catch {
return false;
}
}
export function saveDraft(k: string, d: CheckDraft): void {
try {
sessionStorage.setItem(k, JSON.stringify(d));
} catch {} // 캐시 못 씀 = 이 화면에만 남음
document.dispatchEvent(new Event("m02-hopyo-draft")); // 엑셀 보기 창 다시 칠(779-4 SheetNav.HOPYO_DRAFT)
}
export function dropDraft(k: string): void {
try {
sessionStorage.removeItem(k);
} catch {
/* 없음 */
}
}
/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */
export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => {
const got = d.대상[line.차례] ?? line.맞춤.대상;
return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음
};
/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */
export const isChecked = (line: HopyoLine, d: CheckDraft): boolean =>
d.체크[line.차례] ?? !!line.맞춤.체크;
/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */
export function checkable(line: HopyoLine, d: CheckDraft): boolean {
if (d.뺌[line.차례]) return true;
if (line.맞춤.상태 === "%") return !!partsOf(line, d)[0]?.비율; // 729-3 밑수 고른 % 줄만
const parts = partsOf(line, d);
return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
}
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
export function toTarget(p: HopyoPart): BuildTarget {
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
const link = (["노임", "기계", "비율"] as const).find((x) => p[x]); // 729-3 사용자 이음(엑셀 숫자 + 이음)
if (link) return { [link]: p[link], 배: 1 } as BuildTarget;
const head = p.로직
? { 로직: p.로직, 입력값: p.입력값 ?? {} }
: p.전국
? { 전국: true }
: { 자재: p.자재 };
return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) };
}
/** 체크한 줄만 → 만들기 몸 `줄` */
export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] =>
lines
.filter((l) => isChecked(l, d) && checkable(l, d))
.map((l) =>
d.뺌[l.차례]
? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] }
: { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) },
);
/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */
export function leftOut(lines: HopyoLine[], d: CheckDraft): Record<string, number> {
const out: Record<string, number> = {};
for (const l of lines) {
const why =
l.맞춤.상태 === "%"
? "% 줄"
: !isChecked(l, d) || !checkable(l, d)
? "확인 안 함"
: d.뺌[l.차례]
? "뺌"
: null;
if (why) out[why] = (out[why] ?? 0) + 1;
}
return out;
}
const n4 = (v: number | undefined): string => (v === undefined ? "–" : formatNumber(v));
/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */
export function compareText(q: HopyoMatch["품"] | undefined): string {
if (!q) return "";
const ex = q.엑셀.인력 ?? {};
const ours = q.우리.인력 ?? {};
const mark = (k: string): string => (q.같음[k] ? "✓" : "≠");
const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map(
(k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`,
);
// 같은 갈래 여러 줄은 합쳐 한 줄(서버 `_qty` 와 같은 열쇠 — 갈래 가나다 차례 + 로 묶음)
const name = (g: string | string[]): string => [g].flat().sort().join("+");
const sum = (xs: { 갈래: string | string[]; 시간: number }[], k: string): number | undefined =>
xs
.filter((y) => name(y.갈래) === k)
.reduce<number | undefined>((s, y) => (s ?? 0) + y.시간, undefined);
for (const k of new Set((q.엑셀.장비 ?? []).map((x) => name(x.갈래)))) {
out.push(`${k} ${n4(sum(q.엑셀.장비 ?? [], k))} → ${n4(sum(q.우리.장비 ?? [], k))} ${mark(k)}`);
}
return out.join(" · ");
}
/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */
export function newest(): (key: number) => () => boolean {
const last = new Map<number, number>();
return (key) => {
const my = (last.get(key) ?? 0) + 1;
last.set(key, my);
return () => last.get(key) === my;
};
}
/** [맞음 · 별칭 일괄 체크] 대상 줄 */
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
v === null || v === undefined ? "" : formatPrice(v);
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const openSheet = (o: HopyoSheetOptions): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
export interface CheckOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
no: number;
/** 사전조합을 만들 층(없으면 파일 층) */
target?: HopyoScope;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
canWrite: boolean;
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
}
/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const root = el("div", {
className: "m02-hopyo m02-hopyo-check",
attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` },
});
const target = opt.target ?? opt.scope;
const k = draftKey(opt.scope, opt.key, opt.no);
let d = loadDraft(k);
let got: HopyoOne | null = null;
let built: HopyoBuilt | null = null;
let seq = 0;
let timer = 0;
const ask = newest(); // 줄마다 최신 미리 셈만
const askPrice = newest(); // 줄마다 최신 금액 미리 셈만
const priced = new Map<number, { sig: string; got: LinePrice }>();
let pending = false;
const keep = (): void => saveDraft(k, d);
async function load(quiet = false): Promise<void> {
const my = ++seq;
const stop = quiet ? () => {} : showLoading(root);
try {
const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target);
if (my !== seq) return;
got = one;
const made = one.호표.만든?.품셈; // 705-3 — 만든 사전조합의 품셈 줄 = 첫 체크
if (made && !hasDraft(k)) for (const l of one.호표.줄) d.체크[l.차례] = made.includes(l.차례);
draw();
} catch (error) {
if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load()));
} finally {
stop();
}
}
/** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */
function setParts(line: HopyoLine, parts: HopyoPart[]): void {
d.대상[line.차례] = parts;
d.체크[line.차례] = true;
delete d.뺌[line.차례];
keep();
draw();
const mine = ask(line.차례);
guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => {
if (!mine()) return;
if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상;
if (g.품) d.품[line.차례] = g.품;
keep();
draw();
}, fail);
}
function editOptions(line: HopyoLine, at: number): void {
const parts = partsOf(line, d);
const p = parts[at];
if (!p?.로직) return;
fetchLogicInputs([p.로직]).then(
(res) =>
openLaborInput({
title: `${p.로직} ${p.이름 ?? ""}`.trim(),
logic: res.로직[0] ?? null,
values: (p.입력값 ?? {}) as never,
memo: null,
readOnly: !opt.canWrite,
done: (r) =>
setParts(
line,
parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)),
),
}),
fail,
);
}
/** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */
async function showSource(line: HopyoLine): Promise<void> {
const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : []));
const ours = codes.length
? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
)
: [];
openModal({
title: `원문 — ${line.이름}`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo__src",
mount: (body) => {
const part = (head: string, list: string[], cut: (s: string) => string): void => {
body.append(el("h4", { text: head }));
if (!list.length) body.append(note("없음"));
for (const s of list) {
const view = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
view.show(cut(s));
body.append(note(s), view.root);
}
};
part("엑셀 인용 조항", excel, (s) => s);
part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — "));
},
});
}
/** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */
function price(line: HopyoLine): LinePrice | null {
const doc = draftDoc(line, partsOf(line, d));
if (!doc) return null;
const sig = JSON.stringify(doc);
const have = priced.get(line.차례);
if (have?.sig === sig) return have.got;
const slot = { sig, got: {} as LinePrice };
priced.set(line.차례, slot);
const mine = askPrice(line.차례);
calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then(
(calc) => mine() && ((slot.got = { calc }), soon()),
(error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()),
);
return slot.got;
}
/** 응답 여럿 = 한 번 그림(뒤 탭에서도 — rAF 는 멈춤) */
function soon(): void {
if (pending) return;
pending = true;
window.setTimeout(() => {
pending = false;
if (root.isConnected) draw();
}, 0);
}
const ctx = (): RowsContext => ({
canWrite: opt.canWrite,
d,
price,
check: (line, on) => {
d.체크[line.차례] = on;
keep();
draw();
},
out: (line, value) => {
if (value) d.뺌[line.차례] = value;
else delete d.뺌[line.차례];
d.체크[line.차례] = !!value || d.체크[line.차례];
keep();
draw();
},
times: (line, at, value) => {
d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x));
keep();
draw();
},
choose: (line) =>
openHopyoChoose({
line,
openSheet: sheetAt,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
source: (line) => void showSource(line),
sheet: sheetAt,
options: editOptions,
});
const sheetAt = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
/** 1 단위 금액(705-3) — 품셈 줄 = 우리 미리 셈 · 고정 줄 = 엑셀 · 셈 중 = null */
function sumOf(lines: HopyoLine[], poom: (l: HopyoLine) => boolean): number | null {
let total = 0;
for (const l of lines) {
const p = poom(l) ? price(l) : null;
if (p && !p.calc && !p.fail) return null;
total += p?.calc?.반영합?.계 ?? l.금액.계 ?? 0;
}
return total;
}
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const lines = whole ? [] : bodyOf(h.줄, d);
const poom = (l: HopyoLine): boolean => isChecked(l, d) && checkable(l, d) && !d.뺌[l.차례];
const now = (l: HopyoLine): boolean => (h.만든?.품셈 ?? []).includes(l.차례);
const outs = h.줄.filter((l) => isChecked(l, d) && d.뺌[l.차례] === "안 씀").length;
const put = h.줄.filter(poom);
const diff = put.filter((l) => {
const q = d.품[l.차례] ?? l.맞춤.품;
return !!q && Object.values(q.같음).some((v) => !v);
}).length;
const after = sumOf(h.줄, poom);
const known = !!h.만든?.품셈; // 옛 사전조합(품셈 줄 기록 없음) = 지금 금액 모름
const before = known ? sumOf(h.줄, now) : null;
const money =
`1 단위 ${known ? `지금 ${before === null ? "셈 중" : won(Math.round(before))} → ` : ""}` +
`바뀐 뒤 ${after === null ? "셈 중" : won(Math.round(after))} · 엑셀 ${won(h.금액.계)}`;
const ask = whole
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
: `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` +
` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`;
if (!(await showConfirmDialog(ask, "생성"))) return;
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
showLoadingOverlay();
try {
const res = await buildHopyo(
target,
from,
lines,
whole ? { 대상: h.통째!.대상.map(toTarget) } : null,
);
dropDraft(k);
d = empty();
built = res;
const miss = res.빠진줄.length;
showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success");
document.dispatchEvent(new Event(HOPYO_BUILT));
opt.onBuilt?.(res, h);
await load(true);
} catch (error) {
const at = { scope: opt.scope, target, key: opt.key, 파일: got?.파일 ?? "", 번: opt.no };
if (!openLeftOf(error, at, () => ((d = loadDraft(k)), draw()))) fail(error); // 729-3 남은 줄
} finally {
hideLoadingOverlay();
}
}
function builtBlock(res: HopyoBuilt): HTMLElement {
const c = res.비교;
const rows = res.빠진줄.map((x) =>
el("tr", {
children: [
td(x.이름),
td(x.규격),
td(x.수량 === null ? "" : formatNumber(x.수량), "m02-hopyo__num"),
td(x.단위),
td(won(x.금액), "m02-hopyo__num"),
td(x.까닭),
],
}),
);
return el("div", {
className: "m02-hopyo__block m02-hopyo__built",
attrs: { "data-built": res.참조.키 },
children: [
note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`),
...(rows.length
? [
el("h4", { text: `빠진 줄 ${rows.length}` }),
el("table", {
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [
el("tr", {
children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) =>
el("th", { text: t }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
]
: []),
note(
`값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
),
],
});
}
const toItemButton = (h: HopyoOne["호표"], whole: boolean): HTMLElement =>
createButton({
label: "구조물 조합으로",
size: "sm",
onClick: () =>
void toItem({ scope: opt.scope, target, key: opt.key, no: opt.no, hopyo: h,
lines: whole ? [] : bodyOf(h.줄, d), whole: whole ? { 대상: h.통째!.대상.map(toTarget) } : null }), // prettier-ignore
});
function draw(): void {
if (!got) return;
const h = got.호표;
const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length;
const calc = h.셈;
window.clearTimeout(timer);
if (calc && !calc.끝)
timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS);
const tools: HTMLElement[] = [];
if (opt.canWrite) {
const sure = sureLines(h.줄, d);
const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" });
all.disabled = !sure.length;
all.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
keep();
draw();
});
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const go = createButton({
label: `${L("M02_PrecomboTitle")} 생성`,
variant: "filled",
size: "sm",
});
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all);
if (h.만든 && !whole) {
// 705-3 — 고정 ↔ 품셈 바꾸기(줄 하나 = 체크 하나 + [사전조합 생성])
const poom = createButton({ label: `품셈으로 바꾸기 (${sure.length})`, size: "sm" });
poom.setAttribute("data-hopyo-to-poom", "");
poom.disabled = !sure.length;
poom.addEventListener("click", () => {
for (const l of sure) d.체크[l.차례] = true;
keep();
draw();
void make(h);
});
const back = createButton({ label: "모두 고정으로", size: "sm" });
back.setAttribute("data-hopyo-to-fixed", "");
back.disabled = !h.만든.품셈?.length;
back.addEventListener("click", () => {
for (const l of h.줄) d.체크[l.차례] = false;
keep();
draw();
void make(h);
});
tools.push(poom, back);
}
tools.push(go, toItemButton(h, whole)); // 704-4 — 사전조합 안 거치고 구조물 조합
}
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note("금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈)"),
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
...(calc && !calc.끝
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
: []),
...(h.만든
? [
note(
`만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` +
(h.만든.품셈
? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}`
: ""),
),
]
: []),
createButton({
label: "엑셀 보기",
size: "sm",
onClick: () =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got!.파일,
canWrite: opt.canWrite,
target,
호표: opt.no,
}),
}),
...tools,
],
});
const whole = h.통째
? (() => {
const box = el("input", {
attrs: { type: "checkbox", "data-hopyo-whole": "" },
}) as HTMLInputElement;
box.checked = d.통째 ?? h.통째.체크;
box.disabled = !opt.canWrite;
box.addEventListener("change", () => {
d.통째 = box.checked;
keep();
draw();
});
return [
el("label", {
className: "ui-toolbar m02-hopyo__bar m02-hopyo__whole",
children: [
box,
note(
`통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`,
),
],
}),
];
})()
: [];
root.replaceChildren(
head,
...whole,
...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]),
...(built ? [builtBlock(built)] : []),
);
}
void load();
return root;
}
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
openModal({
title: opt.title ?? `확인 — 제${opt.no}호표`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo__check-modal",
mount: (body, close) =>
body.append(
buildHopyoCheck({
...opt,
onBuilt: (got, h) => {
opt.onBuilt?.(got, h);
close();
},
}),
),
});
}