- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만 - 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT) - 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query) - 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기 - 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
681 lines
24 KiB
TypeScript
681 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* M02_MasterTemplete_Hopyo_Check.ts
|
||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
|
||
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
|
||
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
|
||
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
|
||
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
|
||
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
|
||
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
|
||
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
|
||
* ========================================================================== */
|
||
|
||
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,
|
||
inputsText,
|
||
openHopyoChoose,
|
||
sourceText,
|
||
targetText,
|
||
} from "./M02_MasterTemplete_Hopyo_Choose";
|
||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||
import "./M02_MasterTemplete_Hopyo.css";
|
||
|
||
export const POLL_MS = 3000;
|
||
const OUT = ["우리에 없음", "안 씀"] as const;
|
||
|
||
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
|
||
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 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)}`,
|
||
);
|
||
const name = (g: string | string[]): string => [g].flat().join("+");
|
||
for (const x of q.엑셀.장비 ?? []) {
|
||
const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래));
|
||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`);
|
||
}
|
||
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;
|
||
/** [← 호표 목록](본 칸) */
|
||
onBack?: () => void;
|
||
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
|
||
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
|
||
}
|
||
|
||
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
|
||
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 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(" — "));
|
||
},
|
||
});
|
||
}
|
||
|
||
function partCell(line: HopyoLine): HTMLElement {
|
||
const parts = partsOf(line, d);
|
||
return el("td", {
|
||
className: "m02-hopyo__parts",
|
||
children: parts.map((p, at) => {
|
||
const times = el("input", {
|
||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
|
||
}) as HTMLInputElement;
|
||
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
|
||
times.disabled = !opt.canWrite;
|
||
times.addEventListener("change", () => {
|
||
const v = times.value === "" ? null : Number(times.value);
|
||
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
|
||
keep();
|
||
draw();
|
||
});
|
||
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
|
||
return el("div", {
|
||
className: "m02-hopyo__part",
|
||
children: [
|
||
el("span", {
|
||
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
|
||
attrs: { title: p.어디 ?? "" },
|
||
}),
|
||
times,
|
||
...(p.로직
|
||
? [
|
||
createButton({
|
||
label: "옵션",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => editOptions(line, at),
|
||
}),
|
||
]
|
||
: []),
|
||
],
|
||
});
|
||
}),
|
||
});
|
||
}
|
||
|
||
function lineRow(line: HopyoLine): HTMLElement {
|
||
const m = line.맞춤;
|
||
const parts = partsOf(line, d);
|
||
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
|
||
const input = box as HTMLInputElement;
|
||
input.checked = isChecked(line, d) && checkable(line, d);
|
||
input.disabled = !opt.canWrite || !checkable(line, d);
|
||
input.addEventListener("change", () => {
|
||
d.체크[line.차례] = input.checked;
|
||
keep();
|
||
draw();
|
||
});
|
||
const out = el("select", {
|
||
className: "ui-input ui-input--sm",
|
||
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
|
||
children: [
|
||
el("option", { text: "넣음", attrs: { value: "" } }),
|
||
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
|
||
],
|
||
}) as HTMLSelectElement;
|
||
out.value = d.뺌[line.차례] ?? "";
|
||
out.disabled = !opt.canWrite || m.상태 === "%";
|
||
out.addEventListener("change", () => {
|
||
if (out.value) d.뺌[line.차례] = out.value;
|
||
else delete d.뺌[line.차례];
|
||
d.체크[line.차례] = !!out.value || d.체크[line.차례];
|
||
keep();
|
||
draw();
|
||
});
|
||
const sheet = (행: [number, number]): void =>
|
||
openSheet({
|
||
scope: opt.scope,
|
||
key: opt.key,
|
||
파일: got?.파일 ?? "",
|
||
canWrite: opt.canWrite,
|
||
target,
|
||
단산행: 행,
|
||
});
|
||
const acts = [
|
||
...(opt.canWrite && m.상태 !== "%"
|
||
? [
|
||
createButton({
|
||
label: "고르기",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () =>
|
||
openHopyoChoose({
|
||
line,
|
||
openSheet: sheet,
|
||
done: (ts) =>
|
||
setParts(
|
||
line,
|
||
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
|
||
),
|
||
}),
|
||
}),
|
||
]
|
||
: []),
|
||
createButton({
|
||
label: "원문 보기",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => void showSource(line),
|
||
}),
|
||
...(line.근거
|
||
? [
|
||
createButton({
|
||
label: "엑셀에서 보기",
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => sheet(line.근거!.행),
|
||
}),
|
||
]
|
||
: []),
|
||
out,
|
||
];
|
||
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
|
||
const opts = parts
|
||
.filter((p) => p.로직)
|
||
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
|
||
.filter(Boolean);
|
||
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
|
||
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
|
||
return el("tr", {
|
||
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
|
||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||
children: [
|
||
el("td", { children: [box] }),
|
||
td(line.갈래),
|
||
td(line.이름),
|
||
td(line.규격),
|
||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||
td(line.단위),
|
||
el("td", {
|
||
attrs: { title: tags },
|
||
children: [
|
||
el("span", {
|
||
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
|
||
text: `${m.상태}${alias}`,
|
||
}),
|
||
],
|
||
}),
|
||
partCell(line),
|
||
td(opts.join(" / ")),
|
||
td(compareText(d.품[line.차례] ?? m.품)),
|
||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||
],
|
||
});
|
||
}
|
||
|
||
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");
|
||
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 back = opt.onBack
|
||
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
|
||
: [];
|
||
const head = el("div", {
|
||
className: "m02-hopyo__bar",
|
||
children: [
|
||
...back,
|
||
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.통째.글}` : ""}`,
|
||
),
|
||
],
|
||
}),
|
||
];
|
||
})()
|
||
: [];
|
||
const heads = [
|
||
"",
|
||
"갈래",
|
||
"품명",
|
||
"규격",
|
||
"수량",
|
||
"단위",
|
||
"상태",
|
||
"우리 로직 · 배",
|
||
"옵션",
|
||
"품 비교",
|
||
"",
|
||
];
|
||
root.replaceChildren(
|
||
head,
|
||
...whole,
|
||
h.줄.length
|
||
? el("div", {
|
||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||
children: [
|
||
el("table", {
|
||
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
|
||
children: [
|
||
el("thead", {
|
||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||
}),
|
||
el("tbody", { children: h.줄.map(lineRow) }),
|
||
],
|
||
}),
|
||
],
|
||
})
|
||
: 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();
|
||
},
|
||
}),
|
||
),
|
||
});
|
||
}
|