Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts
T
eomsangdonandClaude Opus 5.5 56a3e0538f feat(814-1 1·2): 올리기 파일 하나 · 확장자 파일 = 단가산출표 판 + 산출근거
- 같은 공사 엑셀 · 원파일 붙이기 걷음(keep · upload_native · same 지움) — 엑셀이든 .estx · .STC 든 하나만
- 원파일 = 새 HP · 산출근거 머리(코드 · 이름 · 규격 · 단위 · 저장 금액) 단가산출표 격자 · 일위대가 안 만듦(HeadMainDat 안 씀)
- 화면 = 일위대가 없으면 단가산출 목록 먼저 · 올림 안내 글 하나로
- 시험 810-2: 붙음 · EST 호표 → SC 기대 지움(요구 742-1 · 743-2 로 걷은 길) → 파일 하나 · 단가산출표 판 기대

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:14:21 +09:00

336 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_Sheet.ts
* 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 ·
* 머리 막대 = 시트 · 호표 수 · 검산 · 연결 n / 줄 m(809-1 줄 표지 · 연결됨 + 로직 줄) + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) ·
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 ·
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) ·
* 오른쪽 = 호표 목록(제N호표 [수정] · [내 일위대가로] · 찾기 · 연결 n/m — 다 연결이면 녹색) ·
* 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`).
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
* · [수정] = 일위대가 컨테이너 꼴 모달(809-1 `Hopyo_View` · 경쟁사 로직 인정 · 요소만 연결 · 줄 누름과 따로) ·
* 옮기기 = [내 일위대가로](807-1) 하나 · 단가산출 탭 블록 = [가져온 식으로 열기](`Hopyo_SheetSk`) · 기초 판정 · [옮기기] 판 없음(요구 703)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨
* · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움
* (모두 고정 서버 길은 B09 · 붙여넣기가 씀)
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import {
readHopyo,
type HopyoBrief,
type HopyoGrid,
type HopyoSpan,
type HopyoView,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine";
import { openHopyoView } from "./M02_MasterTemplete_Hopyo_View";
import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk";
import "./M02_MasterTemplete_Hopyo.css";
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const note = (text: string): HTMLElement =>
el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } });
export interface HopyoSheetOptions {
/** 실무자료 파일 층 */
scope: HopyoScope;
key: string;
파일: string;
/** 만들 층 쓰기 권한 — 없으면 단가산출 모달 읽기만 */
canWrite: boolean;
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
target?: HopyoScope;
/** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */
호표?: number;
/** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */
단산행?: number | [number, number];
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
onClose?: () => void;
/** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */
headExtra?: HTMLElement[];
/** 단가산출 목록 읽은 뒤 */
onSkChange?: () => void;
/** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */
onRead?: (file: HopyoView) => void;
}
/** 몸 — 머리 막대 · 어긋남 글 · 시트 + 목록(세로로 쌓는 자리에 이어 붙임) */
export interface HopyoSheetBody {
parts: HTMLElement[];
/** 몸을 걷음(마지막 창 손잡이 · 격자 · 스프레드시트) */
destroy: () => void;
/** 안 옮긴 단가산출 블록 수(판정 전 null) */
skCount: () => { 블록: number; 밖: number; 줄: number } | null;
/** 그 단가산출 블록으로 뜀 */
showSk: (번: number) => void;
}
const ILWI = "일위대가";
const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate · 역할 id 없는 옛 격자)
const sheetId = (o: HopyoSheetOptions): string =>
`${o.scope.layer}:${o.scope.owner ?? ""}:${o.key}`;
/** 목록 숫자 「연결된 줄 / 연결할 줄」(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;
}
/** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */
export const rowsText = (행: number | [number, number]): string =>
typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`;
export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody {
const id = sheetId(opt);
let sheet: SpreadsheetHandle | null = null;
let nav: Nav.SheetNav | null = null;
let sk: SheetSk | null = null; // 단가산출 탭 오른쪽 판(780-2)
let detail = ""; // 단가산출 상세 시트 id
let closed = false;
let briefs: HopyoBrief[] = [];
let spans: HopyoSpan[] = [];
let ilwi = ""; // 일위대가 시트 id
let last = "O";
let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름
let current: number | null = opt.호표 ?? null;
const items = new Map<number, HTMLElement>();
const target = opt.target ?? opt.scope;
const head = el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [note("엑셀을 읽는 중…")],
});
const warn = el("p", {
className: "ui-text ui-text--warning m02-hopyo__warn",
attrs: { "data-text": "14" },
});
warn.hidden = true;
const left = el("div", {
className: "m02-hopyo-sheet__grid",
children: [note("엑셀을 읽는 중…")],
});
const col = el("div", { className: "m02-hopyo-sheet__col", children: [left] });
const searchField = createInputField({
type: "search",
placeholder: "호표 · 품명 · 규격 찾기",
compact: true,
onInput: () => paintList(),
});
const search = searchField.input;
const list = el("div", { className: "m02-hopyo-sheet__list" });
const count = note("");
const right = el("div", {
className: "m02-hopyo-sheet__side",
children: [
searchField.root,
el("div", {
className: "ui-toolbar m02-hopyo__bar",
children: [count],
}),
list,
],
});
const finish = (): void => {
if (closed) return;
closed = true;
sk?.destroy();
nav?.destroy();
sheet?.destroy();
opt.onClose?.();
};
/** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */
function paintHead(file: HopyoView): void {
const wrong = file.검산.어긋남;
head.replaceChildren(
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
note(`연결 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
...(opt.headExtra ?? []),
);
opt.onRead?.(file);
warn.textContent = wrong.join("\n");
warn.hidden = !wrong.length;
}
/** 목록 강조 · 보이게(시트 고름은 그대로) */
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);
}
/** [수정] — 그 호표 자리 + 일위대가 컨테이너 꼴 모달(809-1 · 줄 누름과 따로) */
const editButton = (h: HopyoBrief): HTMLElement => {
const b = createButton({
label: "수정",
variant: "ghost",
size: "sm",
onClick: (e) => {
e.stopPropagation();
focus(h.번);
openHopyoView({
file: opt.scope,
key: opt.key,
target,
no: h.번,
brief: h,
canWrite: opt.canWrite,
onSheet: () => focus(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 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: [
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
editButton(h),
mineButton(opt.scope, opt.key, h), // [내 일위대가로](807-1)
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}`;
}
async function mountSheet(grid: HopyoGrid): Promise<void> {
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
const { fillMissingWidths } = await import("../A00_Common/spreadsheet/spreadsheet_autofit");
if (closed) return;
const book = structuredClone(grid.책);
fillMissingWidths(book); // 엑셀 폭이 없는 열 = 글 길이로 처음 폭
const ids = Nav.nameTabs(book, grid); // 탭 글 통일(779-4)
const first = book.시트.find((s) => s.id === ids.일위대가표 || s.이름 === ILWI) ?? book.시트[0];
if (!first) return void left.replaceChildren(note("시트 없음"));
ilwi = first.id;
detail = ids.단가산출상세 ?? "";
last = lastColumn(first);
book.활성 = first.id;
left.replaceChildren();
const lastOf = (sid: string): string =>
lastColumn(book.시트.find((x) => x.id === sid) ?? first);
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left };
nav = ids.일위대가표 || ids.단가산출표 ? Nav.createSheetNav(more) : null; // 원파일 = 단가산출표 판만(814-1)
if (nav) warn.after(nav.root);
sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore
sheet = createSpreadsheet(left, book, {
readOnly: true,
칠: nav?.fill,
onSelect: (s) => {
at = s;
nav?.at(s);
sk?.at(s);
if (s.시트 !== ilwi) return;
const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0]));
if (no !== null && no !== current) mark(no);
},
});
if (!ids.일위대가표 && ids.단가산출표) sk?.at({ 시트: first.id, 칸: "A1" }); // 일위대가 없음 = 단가산출 목록 먼저
nav?.marks(briefs);
// 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로)
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return;
const row = Number(/\d+/.exec(at.칸)?.[0]);
const no = hopyoAtRow(spans, row);
if (no !== null && spans.find((h) => h.번 === no)?.행[0] === row) focus(no);
});
}
async function load(): Promise<void> {
try {
const [file, grid] = await Promise.all([
readHopyo(opt.scope, opt.key),
Nav.gridOf(id, opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
]);
if (closed) return;
briefs = file.호표;
spans = grid instanceof Error ? [] : grid.호표;
paintHead(file);
paintList();
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
await mountSheet(grid);
const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행);
if (rows) [detail, ...DANSAN].some((name) => name && sheet?.select(rows, name));
else if (current !== null) focus(current);
} catch (error) {
if (!closed) {
head.replaceChildren(note(`못 읽음 — ${why(error)}`));
left.replaceChildren(note(`못 읽음 — ${why(error)}`));
}
}
}
void load();
return {
parts: [head, warn, el("div", { className: "m02-hopyo-sheet", children: [col, right] })],
destroy: finish,
skCount: () => sk?.count() ?? null,
showSk: (번) => sk?.show(번),
};
}