- 새 Hopyo_Check(모달 껍데기 · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 일위대가 ·
[호표 제안 모두 확인](새) · [일위대가 생성] = 옮기기 길 이 호표만 · [구조물 조합으로] · [엑셀 보기])
- 새 Hopyo_CheckRows(로직 표 + 자재 표 = 구조물 조합 상세 꼴 · Labor_Rows moneyCell · PRICE_SLOTS ·
Labor_MatMarks 빌려 씀 · 칸마다 엑셀 / 우리 단가 · 품 비교 · [고르기] · [원문 보기] · [옵션] · 추가 / 로직없음)
- 새 Hopyo_ToItem(구조물 조합 나무 add-item → 그 일위대가 문서에 SC 참조 한 줄 · 옮겨진 뒤만 켬)
- Hopyo_Rows: 시트 아래 줄 판 몸 걷음 → 줄 칸 부품 createRowKit(판정 · 고름 칩 · 블록 풀이 · 단위 환산 · [확인]) ·
옮기기 한 번 runMove(오른쪽 [옮기기] · 모달 [일위대가 생성] 한 벌)
- Router: POST /hopyo/{키}/price 우리 금액 미리 셈(저장 없음 · Make.Move.pick · Lines.picks → Labor_Doc.calc 재사용)
- Sheet: [수정] = openHopyoCheck · 시트 아래 자리는 단가산출 블록 판(SheetSk) 몫으로 남김
- 안 살림: 맞음·별칭 일괄 체크 · 품셈으로 바꾸기 · 모두 고정으로(지운 서버 길 몫) · 사용안함(새 옮기기 길에 뺌 없음)
- 시험: 새 test_786_2_check_modal(우리 금액 예시 1 = 94,227 · 예시 3 입력 필요 → 0 · 쓰기 0 · 모달 · 새 배지 ·
공용 import 만 · 날 button/input 0) · test_642_6 단언 = 줄 판 createJudgeRows → 모달 openHopyoCheck(까닭: 줄 판 몸 걷음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
16 KiB
TypeScript
399 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Hopyo_Sheet.ts
|
|
* 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 ·
|
|
* 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m(1차 판정) · 블록 풀이 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) ·
|
|
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 ·
|
|
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) ·
|
|
* 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) ·
|
|
* 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`).
|
|
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
|
|
* · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 ·
|
|
* 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌)
|
|
* · 격자 = 서버 `/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 {
|
|
POLL_MS,
|
|
readHopyo,
|
|
type HopyoBrief,
|
|
type HopyoGrid,
|
|
type HopyoSpan,
|
|
type HopyoView,
|
|
type HopyoScope,
|
|
} from "./M02_MasterTemplete_Hopyo_Api";
|
|
import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
|
import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows";
|
|
import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks";
|
|
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 state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6)
|
|
let panel: { destroy: () => void } | null = null; // 시트 아래 블록 판 몸
|
|
const offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠
|
|
|
|
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 lines = el("div", {
|
|
className: "m02-hopyo-sheet__lines",
|
|
attrs: { "data-hopyo-lines": "" },
|
|
}); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk)
|
|
lines.hidden = true;
|
|
const closeLines = (): void => {
|
|
panel?.destroy();
|
|
panel = null;
|
|
lines.hidden = true;
|
|
};
|
|
const openLines = (make: (host: HTMLElement) => { destroy: () => void }): void => {
|
|
panel?.destroy();
|
|
lines.hidden = false;
|
|
panel = make(lines);
|
|
};
|
|
const col = el("div", { className: "m02-hopyo-sheet__col", children: [left, lines] });
|
|
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 move = createJudgeMove({
|
|
file: opt.scope,
|
|
key: opt.key,
|
|
target,
|
|
state,
|
|
readOnly: !opt.canWrite,
|
|
onMoved: () => void refresh(),
|
|
});
|
|
const right = el("div", {
|
|
className: "m02-hopyo-sheet__side",
|
|
children: [
|
|
searchField.root,
|
|
el("div", {
|
|
className: "ui-toolbar m02-hopyo__bar",
|
|
children: [count],
|
|
}),
|
|
list,
|
|
move.root,
|
|
],
|
|
});
|
|
|
|
const finish = (): void => {
|
|
if (closed) return;
|
|
closed = true;
|
|
closeLines();
|
|
offPaint();
|
|
move.destroy();
|
|
sk?.destroy();
|
|
nav?.destroy();
|
|
sheet?.destroy();
|
|
opt.onClose?.();
|
|
};
|
|
|
|
/** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */
|
|
function paintHead(file: HopyoView): void {
|
|
const wrong = file.검산.어긋남;
|
|
const busy = !!file.셈 && !file.셈.끝;
|
|
head.replaceChildren(
|
|
note(`시트 ${file.시트}`),
|
|
note(`호표 ${file.호표수}`),
|
|
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
|
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
|
...(busy ? [note(`${file.셈?.까닭 ?? "블록 풀이"} 셈 중`)] : []),
|
|
...(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);
|
|
}
|
|
|
|
/** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */
|
|
const editButton = (h: HopyoBrief): HTMLElement => {
|
|
const b = createButton({
|
|
label: "수정",
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: (e) => {
|
|
e.stopPropagation();
|
|
focus(h.번);
|
|
openHopyoCheck({
|
|
file: opt.scope,
|
|
key: opt.key,
|
|
target,
|
|
state,
|
|
no: h.번,
|
|
brief: h,
|
|
canWrite: opt.canWrite,
|
|
onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r),
|
|
onHopyo: () => focus(h.번),
|
|
onMoved: () => void refresh(),
|
|
});
|
|
},
|
|
});
|
|
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),
|
|
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 judged = (no: number, at: number): boolean => !!lineOf(state.get(), no, at)?.확인;
|
|
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left, judged };
|
|
nav = ids.일위대가표 ? Nav.createSheetNav(more) : null;
|
|
if (nav) warn.after(nav.root);
|
|
sk = nav ? createSheetSk({ ...opt, target, nav, state, side: right, openLines, closeLines, onMoved: () => void refresh(), ...(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);
|
|
},
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
|
|
/** 뒤 숫자 판정 셈 중이면 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();
|
|
nav?.marks(briefs);
|
|
poll(next);
|
|
}, POLL_MS);
|
|
}
|
|
|
|
/** 옮긴 뒤 — 목록 · 칠 · 단가산출 목록 다시 읽음 */
|
|
async function refresh(): Promise<void> {
|
|
const next = await readHopyo(opt.scope, opt.key).catch(() => null);
|
|
if (!next || closed) return;
|
|
briefs = next.호표;
|
|
paintHead(next);
|
|
paintList();
|
|
nav?.marks(briefs);
|
|
sk?.reload();
|
|
opt.onSkChange?.();
|
|
}
|
|
|
|
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();
|
|
poll(file);
|
|
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(번),
|
|
};
|
|
}
|