Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts
T
eomsangdonandClaude Opus 5.5 dd49b554b4 feat(M02): 359-3 실무자료 확인 창 표 = 일위대가 상세 꼴 · 금액 미리 셈 · 드롭다운 추가 · 로직없음 · 사용안함
- 줄 표 = 로직 표(차례 · 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 노무비 · 재료비 · 경비 · 합계) + 자재 표(세 칸 · 가격 다섯) + 품 비교 · 단추
- 금액 칸 = 위 엑셀 · 아래 우리(줄마다 서버 calc 미리 셈 · 저장 안 함 · 최신 응답만)
- 미리 셈 문서 꼴 = logic/M02_MasterTemplete_Hopyo_Draft.ts(정본 = M02_Hopyo_Build · 수량 = 엑셀 × 배 · 별도계상)
- 드롭다운 글 추가 · 로직없음 · 사용안함(저장 값 그대로) · [엑셀에서 보기] → [엑셀 보기] · [사전조합 생성]
- Check.ts 쪼갬(표 그리기 = Hopyo_CheckRows.ts) · 쓰지 않는 [← 호표 목록] 지움
- 시험 test_m02_hopyo_draft_calc(북부청 실물 줄 → calc 200 · 반영합 · 저장 안 함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 20:52:22 +09:00

585 lines
21 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_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:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* 계산은 서버(`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 { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import {
buildHopyo,
guessHopyo,
readHopyoOne,
type BuildLine,
type BuildTarget,
type HopyoBuilt,
type HopyoLine,
type HopyoMatch,
type HopyoOne,
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
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 "./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 {
/* 캐시 못 씀 = 이 화면에만 남음 */
}
}
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 (line.맞춤.상태 === "%") return false;
if (d.뺌[line.차례]) return true;
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 head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: 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 ? "–" : String(Math.round(v * 10000) / 10000);
/** 품 비교 글 — 「콘크리트공 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 ? "" : v.toLocaleString("ko-KR");
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
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;
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;
}
/** 응답 여럿 = 한 번 그림 */
function soon(): void {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
if (root.isConnected) draw();
});
}
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,
단산행: 행,
});
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const lines = whole ? [] : bodyOf(h.줄, d);
const out = leftOut(h.줄, d);
const put = lines.filter((x) => x.대상).length;
const outs = lines.length - put;
const ask = whole
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
: `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` +
(Object.keys(out).length
? `(${Object.entries(out)
.map(([a, b]) => `${a} ${b}`)
.join(" · ")})`
: "") +
" — 사전조합 생성";
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) {
fail(error);
} 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 ? "" : String(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.까닭}` : ""}`,
),
],
});
}
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: "사전조합 생성", variant: "filled", size: "sm" });
go.disabled = !whole && checked < 1;
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all, go);
}
const head = el("div", {
className: "m02-hopyo__bar",
children: [
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
note(`엑셀 단가 ${won(h.금액.계)}`),
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
...(calc && !calc.끝
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
: []),
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
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: "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();
},
}),
),
});
}