Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts
T
eomsangdonandClaude Sonnet 5.5 373f6703fe feat(M02): 실무자료 [저장] = 개인 층 · 복사 때 본뜬 지킴 · 단가산출 지정 영역 B~F 열 (372-2)
엑셀 보기 창 만들기 대상 개인 층 고정 · [삭제]는 화면 층 쓰기 권한 때만 · 층 사이 복사가 실무자료 본뜬(키 · 전국자재)을 덮지 않음(원본 참조 = 복사원본 칸).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-17 11:56:28 +09:00

503 lines
20 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Sheet.ts
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 ·
* 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) ·
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) ·
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
deleteHopyo,
readHopyo,
readHopyoGrid,
readHopyoOne,
type HopyoBrief,
type HopyoBuilt,
type HopyoGrid,
type HopyoOne,
type HopyoScope,
type HopyoSpan,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
bodyOf,
draftKey,
hasDraft,
HOPYO_BUILT,
loadDraft,
openHopyoCheck,
partsOf,
POLL_MS,
toTarget,
} from "./M02_MasterTemplete_Hopyo_Check";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoSheetOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
파일: string;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
canWrite: boolean;
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
target?: HopyoScope;
/** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */
호표?: number;
/** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
onClose?: () => void;
/** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */
onDeleted?: () => void;
}
const ILWI = "일위대가";
const DANSAN = "단가산출";
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 note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
/** 목록 숫자 「연결된 줄 / 연결할 줄」(358-3 두 칸 · 없으면 옛 맞춤 [제안, 줄]) — 다 연결(같고 0 넘음)이면 참 ·
* 0/0 은 색 없음 */
export function allLinked(h: {
맞춤: [number, number];
연결할?: number;
연결된?: number;
}): boolean {
const [done, all] = h.연결할 === undefined ? h.맞춤 : [h.연결된 ?? 0, h.연결할];
return all > 0 && done === all;
}
/** 목록 숫자 글 — 358-3 두 칸이 있으면 그것 */
const linkText = (h: { 맞춤: [number, number]; 연결할?: number; 연결된?: number }): string =>
h.연결할 === undefined ? `${h.맞춤[0]}/${h.맞춤[1]}` : `${h.연결된 ?? 0}/${h.연결할}`;
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
/** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */
export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`;
/** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */
export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
let best = "A";
for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) {
const c = /^[A-Z]+/.exec(k)?.[0];
if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c;
}
return best;
}
/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */
export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] {
const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)]));
const out: number[] = [];
while (left.size) {
const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n)));
const no = ready ? ready[0] : [...left.keys()][0];
out.push(no);
left.delete(no);
}
return out;
}
/** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */
export const rowsText = (행: number | [number, number]): string =>
typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`;
export function openHopyoSheet(opt: HopyoSheetOptions): void {
let sheet: SpreadsheetHandle | null = null;
let closed = false;
let briefs: HopyoBrief[] = [];
let spans: HopyoSpan[] = [];
let ilwi = ""; // 일위대가 시트 id
let last = "O";
let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름
let current: number | null = opt.호표 ?? null;
const checked = new Set<number>();
const items = new Map<number, HTMLElement>();
const target = opt.target ?? opt.scope;
const head = el("div", { className: "m02-hopyo__bar", children: [note("엑셀을 읽는 중…")] });
const warn = el("p", { className: "m02-hopyo__warn" });
warn.hidden = true;
const left = el("div", {
className: "m02-hopyo-sheet__grid",
children: [note("엑셀을 읽는 중…")],
});
const search = el("input", {
className: "ui-input ui-input--sm",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" },
}) as HTMLInputElement;
const list = el("div", { className: "m02-hopyo-sheet__list" });
const count = note("");
const go = createButton({ label: "저장", variant: "filled", size: "sm" });
go.disabled = true;
go.setAttribute("data-hopyo-save", "");
const result = el("div", { className: "m02-hopyo-sheet__result" });
const right = el("div", {
className: "m02-hopyo-sheet__side",
children: [
search,
el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }),
list,
result,
],
});
const finish = (): void => {
if (closed) return;
closed = true;
sheet?.destroy();
opt.onClose?.();
};
const modal = openModal({
title: `엑셀 보기 — ${opt.파일}`,
closeLabel: "닫기",
wide: true,
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
beforeClose: async () => {
finish();
return true;
},
mount: (body) =>
body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
});
const paintGo = (): void => {
go.querySelector(".ui-btn__label")!.textContent = checked.size
? `저장 (${checked.size})`
: "저장";
go.disabled = !checked.size;
};
/** 머리 막대 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제]) */
function paintHead(file: HopyoView): void {
const wrong = file.검산.어긋남;
const c = file.셈 && !file.셈.끝 ? file.셈 : null;
head.replaceChildren(
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
...(c
? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)]
: []),
...(opt.canWrite && opt.onDeleted ? [dropButton()] : []),
);
warn.textContent = wrong.join("\n");
warn.hidden = !wrong.length;
}
const dropButton = (): HTMLButtonElement =>
createButton({
label: "삭제",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
if (!(await showConfirmDialog(ask, "삭제"))) return;
try {
await deleteHopyo(opt.scope, opt.key);
showToast("지움", "success");
finish();
modal.close();
opt.onDeleted?.();
} catch (error) {
showToast(`실패 — ${why(error)}`, "error");
}
},
});
/** 목록 강조 · 보이게(시트 고름은 그대로) */
function mark(no: number | null): void {
items.get(current ?? NaN)?.classList.remove("is-current");
current = no;
const row = items.get(no ?? NaN);
row?.classList.add("is-current");
row?.scrollIntoView({ block: "nearest" });
}
/** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */
function focus(no: number): void {
mark(no);
const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행;
if (!sheet || !span) return;
const text = spanText(span, last);
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
}
/** [수정] — 그 호표 확인 창(줄 누름과 따로) */
const editButton = (h: HopyoBrief): HTMLElement => {
const b = createButton({
label: "수정",
variant: "ghost",
size: "sm",
onClick: (e) => {
e.stopPropagation();
openHopyoCheck({
scope: opt.scope,
key: opt.key,
no: h.번,
target,
canWrite: opt.canWrite,
onBuilt: (got) => opt.onImport?.(got, h),
});
},
});
b.setAttribute("data-hopyo-edit", String(h.번));
return b;
};
function paintList(): void {
const q = search.value.trim().toLowerCase();
items.clear();
const shown = briefs.filter(
(h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q),
);
list.replaceChildren(
...shown.map((h) => {
const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
box.checked = checked.has(h.번);
box.hidden = !opt.canWrite;
box.addEventListener("click", (e) => e.stopPropagation());
box.addEventListener("change", () => {
if (box.checked) checked.add(h.번);
else checked.delete(h.번);
paintGo();
});
const linked = note(linkText(h));
linked.classList.toggle("is-linked", allLinked(h));
const row = el("div", {
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
children: [
box,
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
editButton(h),
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
linked,
],
});
row.addEventListener("click", () => focus(h.번));
items.set(h.번, row);
return row;
}),
);
count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`;
}
search.addEventListener("input", paintList);
async function mountSheet(grid: HopyoGrid): Promise<void> {
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
if (closed) return;
const book = structuredClone(grid.책);
const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0];
if (!first) return void left.replaceChildren(note("시트 없음"));
ilwi = first.id;
last = lastColumn(first);
book.활성 = first.id;
left.replaceChildren();
sheet = createSpreadsheet(left, book, {
readOnly: true,
onSelect: (s) => {
at = s;
if (s.시트 !== ilwi) return;
const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0]));
if (no !== null && no !== current) mark(no);
},
});
// 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈)
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return;
const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0]));
if (no !== null) focus(no);
});
}
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
function poll(file: HopyoView): void {
if (!file.셈 || file.셈.끝) return;
window.setTimeout(async () => {
const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null);
if (!next || closed) return;
briefs = next.호표;
paintHead(next);
paintList();
poll(next);
}, POLL_MS);
}
async function load(): Promise<void> {
try {
const [file, grid] = await Promise.all([
readHopyo(opt.scope, opt.key),
readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
]);
if (closed) return;
briefs = file.호표;
spans = grid instanceof Error ? [] : grid.호표;
paintHead(file);
paintList();
poll(file);
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
await mountSheet(grid);
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
else if (current !== null) focus(current);
} catch (error) {
if (!closed) {
head.replaceChildren(note(`못 읽음 — ${why(error)}`));
left.replaceChildren(note(`못 읽음 — ${why(error)}`));
}
}
}
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
el("table", {
className: "ui-general-block__table m02-hopyo__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
});
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
const headCell = (no: number, name: string): HTMLElement =>
el("td", { text: `제${no}호표 ${name}`.trim() });
/** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */
async function save(): Promise<void> {
const nos = [...checked].sort((a, b) => a - b);
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
const ask =
`고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` +
(fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : "");
if (!(await showConfirmDialog(ask, "저장"))) return;
go.disabled = true;
const ones: HopyoOne[] = [];
const skipped: HTMLElement[] = [];
showLoadingOverlay();
try {
for (const no of nos) {
if (closed) return;
try {
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
} catch (error) {
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
}
}
} finally {
hideLoadingOverlay();
paintGo();
}
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
for (const one of ones)
if (!ready.includes(one))
skipped.push(
el("tr", {
children: [
headCell(one.호표.번, one.호표.이름),
el("td", { text: "건너뜀 — 체크 줄 없음" }),
],
}),
);
await makeAll(ready, skipped);
}
/** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */
async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise<void> {
const by = new Map(ones.map((one) => [one.호표.번, one]));
const order = buildOrder(
ones.map((one) => {
const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번));
const kids = one.호표.줄.flatMap((l) =>
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
);
return { 번: one.호표.번, 아래: kids };
}),
);
const rows: HTMLElement[] = [];
let ok = 0;
showLoadingOverlay();
try {
for (const no of order) {
const one = by.get(no)!;
const h = briefs.find((x) => x.번 === no) ?? one.호표;
const cell = headCell(no, h.이름);
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
ok += 1;
opt.onImport?.(got, h);
rows.push(
el("tr", {
attrs: { "data-built": got.참조.키 },
children: [
cell,
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
],
}),
);
} catch (error) {
const text =
error instanceof StaleError
? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`
: `실패 — ${why(error)}`;
rows.push(el("tr", { children: [cell, el("td", { text })] }));
}
}
} finally {
hideLoadingOverlay();
}
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
result.replaceChildren(
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
);
const all = order.length + skipped.length;
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
}
go.addEventListener("click", () => void save());
void load();
}