- 새 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
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Hopyo_Sheet.ts
|
|
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
|
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기].
|
|
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
|
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 ·
|
|
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
|
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
|
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
|
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
|
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
createButton,
|
|
el,
|
|
hideLoadingOverlay,
|
|
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 {
|
|
buildHopyo,
|
|
readHopyo,
|
|
readHopyoGrid,
|
|
readHopyoOne,
|
|
type HopyoBrief,
|
|
type HopyoBuilt,
|
|
type HopyoGrid,
|
|
type HopyoOne,
|
|
type HopyoScope,
|
|
type HopyoSpan,
|
|
} from "./M02_MasterTemplete_Hopyo_Api";
|
|
import {
|
|
bodyOf,
|
|
draftKey,
|
|
leftOut,
|
|
loadDraft,
|
|
openHopyoCheck,
|
|
partsOf,
|
|
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;
|
|
}
|
|
|
|
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 });
|
|
|
|
/** 엑셀 행(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;
|
|
}
|
|
|
|
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
|
export const rowsText = (행: number | [number, number]): string =>
|
|
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[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 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: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
|
go.disabled = true;
|
|
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,
|
|
],
|
|
});
|
|
|
|
openModal({
|
|
title: `엑셀 보기 — ${opt.파일}`,
|
|
closeLabel: "닫기",
|
|
wide: true,
|
|
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
|
|
beforeClose: async () => {
|
|
closed = true;
|
|
sheet?.destroy();
|
|
return true;
|
|
},
|
|
mount: (body) =>
|
|
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
|
});
|
|
|
|
const paintGo = (): void => {
|
|
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
|
go.disabled = !checked.size;
|
|
};
|
|
|
|
/** 목록 강조 · 보이게(시트 고름은 그대로) */
|
|
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);
|
|
}
|
|
|
|
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 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.번}호표` }),
|
|
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
|
|
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
|
|
],
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
|
|
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.호표;
|
|
paintList();
|
|
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) 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;
|
|
}
|
|
|
|
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
|
async function review(): Promise<void> {
|
|
const target = opt.target ?? opt.scope;
|
|
const nos = [...checked].sort((a, b) => a - b);
|
|
go.disabled = true;
|
|
const ones: HopyoOne[] = [];
|
|
for (const no of nos) {
|
|
if (closed) return;
|
|
try {
|
|
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
|
} catch (error) {
|
|
showToast(`제${no}호표 — ${why(error)}`, "error");
|
|
}
|
|
}
|
|
paintGo();
|
|
const rows = ones.map((one) => {
|
|
const h = one.호표;
|
|
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
|
const out = Object.entries(leftOut(h.줄, d));
|
|
const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : "";
|
|
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
|
return el("tr", {
|
|
attrs: { "data-review": String(h.번) },
|
|
children: [
|
|
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
|
el("td", {
|
|
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
|
className: "m02-hopyo__num",
|
|
}),
|
|
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
|
el("td", { text: calc }),
|
|
el("td", {
|
|
children: [
|
|
createButton({
|
|
label: "확인 창 열기",
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () =>
|
|
openHopyoCheck({
|
|
scope: opt.scope,
|
|
key: opt.key,
|
|
no: h.번,
|
|
target,
|
|
canWrite: opt.canWrite,
|
|
onBuilt: (got) => opt.onImport?.(got, brief),
|
|
}),
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
});
|
|
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
|
const all = createButton({
|
|
label: `이대로 ${ready.length}개 만들기`,
|
|
variant: "filled",
|
|
size: "sm",
|
|
disabled: !ready.length,
|
|
onClick: () => void makeAll(ready),
|
|
});
|
|
all.setAttribute("data-hopyo-make-all", "");
|
|
result.replaceChildren(
|
|
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
|
el("div", {
|
|
className: "m02-hopyo__bar",
|
|
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
|
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
|
const target = opt.target ?? opt.scope;
|
|
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 head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
|
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: [
|
|
head,
|
|
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) {
|
|
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
|
}
|
|
}
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
|
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
|
}
|
|
go.addEventListener("click", () => void review());
|
|
|
|
void load();
|
|
}
|