feat(M02): 359-1 · 359-2 실무자료 [파일 업로드] · 파일 줄 = 바로 엑셀 보기 · [수정] · [저장] · 다 연결 녹색

- 좌측 [엑셀 올리기] → [파일 업로드](구조물 조합 층 단추 꼴 · 보통 크기 · m02-layers__btn)
- 파일 줄 누름 · 올린 직후 = 엑셀 보기 창 · 본 칸 호표 표(Hopyo_View) 지움 · 고름 표시 = 창 열린 동안
- 엑셀 보기 머리 막대 = 시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제](옛 본 칸 표 몫)
- 호표 목록 제N호표 오른쪽 [수정] = 그 호표 확인 창 · 펼침 표 · [확인 창 열기] 지움
- [고른 N개 확인하고 만들기] → [저장] = 물음 한 번 뒤 체크 줄로 하위 호표부터 생성 · 체크 줄 없음 건너뜀 · 409 안내
- 목록 숫자 다 연결(같고 0 넘음) = 녹색(var(--color-success))
- 별칭 장부 뜻 한 줄(LEDGER_HINT) · 갈래 글 로직없음 · 사용안함 · [변경 취소] · [로직 추가] · [내 층 복사] · [삭제]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 20:41:00 +09:00
co-authored by Claude Opus 5.5
parent ec02165954
commit 9920981e42
9 changed files with 271 additions and 355 deletions
@@ -290,6 +290,15 @@
white-space: nowrap;
}
/* 목록 숫자 — 다 연결(359-2)이면 녹색 · [수정] 은 번호 바로 오른쪽 · 줄 높이 안 커짐 */
.m02-hopyo-sheet__item .m02-hopyo__note.is-linked {
color: var(--color-success);
}
.m02-hopyo-sheet__item > .ui-btn {
flex-shrink: 0;
}
.m02-hopyo-sheet__result {
max-height: 40%;
overflow: auto;
@@ -74,6 +74,14 @@ export function loadDraft(k: string): CheckDraft {
return empty();
}
}
/** 확인 창을 열어 만진 적 있음(캐시 있음) */
export function hasDraft(k: string): boolean {
try {
return sessionStorage.getItem(k) !== null;
} catch {
return false;
}
}
export function saveDraft(k: string, d: CheckDraft): void {
try {
sessionStorage.setItem(k, JSON.stringify(d));
@@ -2,8 +2,9 @@
* M02_MasterTemplete_Hopyo_Ledger.ts
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
* 위 층 줄 [내 층으로 고쳐 씀]
* · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 추가] · 배 · 몫 · [옵션] · [삭제] ·
* 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로)
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
* ========================================================================== */
@@ -24,7 +25,16 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import "./M02_MasterTemplete_Hopyo.css";
export const LEDGER_TITLE = "별칭 장부";
/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */
export const LEDGER_HINT =
"엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안";
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */
export const KIND_TEXT: Record<string, string> = {
짝: "짝",
"우리에 없음": "로직없음",
"안 씀": "사용안함",
};
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
type Edits = Record<string, LedgerEntry | null>;
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
@@ -136,11 +146,11 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
className: "ui-input ui-input--sm",
children: [
el("option", { text: "갈래 전체", attrs: { value: "" } }),
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
],
}) as HTMLSelectElement;
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" });
const body = el("tbody");
const keep = (): void => {
saveEdits(k, edits);
@@ -199,7 +209,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
if (mine && r.갈래 === "짝")
pairs.push(
createButton({
label: "로직 더함",
label: "로직 추가",
variant: "ghost",
size: "sm",
onClick: () =>
@@ -217,7 +227,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
? (() => {
const s = el("select", {
className: "ui-input ui-input--sm",
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
}) as HTMLSelectElement;
s.value = r.갈래;
s.addEventListener("change", () =>
@@ -225,12 +235,12 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
);
return el("td", { children: [s] });
})()
: td(r.갈래);
: td(KIND_TEXT[r.갈래] ?? r.갈래);
const acts: HTMLElement[] = [];
if (mine)
acts.push(
createButton({
label: "지우기",
label: "삭제",
variant: "danger",
size: "sm",
onClick: () => {
@@ -242,7 +252,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
acts.push(
createButton({
label: "내 층으로 고쳐 씀",
label: "내 층 복사",
variant: "ghost",
size: "sm",
onClick: () => {
@@ -316,7 +326,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
className: "m02-hopyo__bar",
children: [
el("strong", { text: LEDGER_TITLE }),
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`),
search,
kind,
...(opt.canWrite ? [save, drop] : []),
@@ -1,14 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Sheet.ts
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m — 뒤 셈 중이면 3 초마다 다시)
* + [고른 n개 확인하고 만들기].
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 ·
* 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) ·
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) ·
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 또는 맞음 · 다른 점 수 ·
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
* ========================================================================== */
@@ -16,14 +17,17 @@ 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,
@@ -38,8 +42,8 @@ import {
import {
bodyOf,
draftKey,
hasDraft,
HOPYO_BUILT,
leftOut,
loadDraft,
openHopyoCheck,
partsOf,
@@ -64,6 +68,10 @@ export interface HopyoSheetOptions {
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
onClose?: () => void;
/** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */
onDeleted?: () => void;
}
const ILWI = "일위대가";
@@ -74,6 +82,9 @@ 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 });
/** 목록 숫자 「연결된 줄 / 연결할 줄」 — 다 연결(같고 0 넘음)이면 참 · 0/0 은 색 없음 */
export const allLinked = (m: [number, number]): boolean => m[1] > 0 && m[0] === m[1];
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
@@ -119,7 +130,11 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
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("엑셀을 읽는 중…")],
@@ -130,8 +145,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
}) as HTMLInputElement;
const list = el("div", { className: "m02-hopyo-sheet__list" });
const count = note("");
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
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",
@@ -143,25 +159,70 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
],
});
openModal({
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 () => {
closed = true;
sheet?.destroy();
finish();
return true;
},
mount: (body) =>
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
});
const paintGo = (): void => {
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
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");
@@ -180,6 +241,28 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
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();
@@ -197,14 +280,17 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
else checked.delete(h.번);
paintGo();
});
const linked = note(`${h.맞춤[0]}/${h.맞춤[1]}`);
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() }),
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
linked,
],
});
row.addEventListener("click", () => focus(h.번));
@@ -243,13 +329,14 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
});
}
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
/** 뒤 숫자 판정 셈 중이면 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);
@@ -264,6 +351,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
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));
@@ -271,7 +359,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
else if (current !== null) focus(current);
} catch (error) {
if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`));
if (!closed) {
head.replaceChildren(note(`못 읽음 — ${why(error)}`));
left.replaceChildren(note(`못 읽음 — ${why(error)}`));
}
}
}
@@ -297,84 +388,50 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
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 review(): Promise<void> {
const target = opt.target ?? opt.scope;
/** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */
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[] = [];
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");
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();
}
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 tally = (s: string): number => h.줄.filter((l) => l.맞춤.상태 === s).length;
const calc =
h.셈 && !h.셈.끝
? `셈 중 ${h.셈.한}/${h.셈.모두}`
: `맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`;
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", {
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
for (const one of ones)
if (!ready.includes(one))
skipped.push(
el("tr", {
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),
}),
}),
headCell(one.호표.번, one.호표.이름),
el("td", { text: "건너뜀 — 체크 줄 없음" }),
],
}),
],
});
});
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("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
}),
);
);
await makeAll(ready, skipped);
}
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
async function makeAll(ones: HopyoOne[]): Promise<void> {
const target = opt.target ?? opt.scope;
/** 하위 호표부터 차례로 만들기 — 하위 확인 전(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) => {
@@ -392,7 +449,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
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() });
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));
@@ -402,7 +459,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
el("tr", {
attrs: { "data-built": got.참조.키 },
children: [
head,
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" }),
@@ -410,17 +467,24 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
}),
);
} catch (error) {
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(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));
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
result.replaceChildren(
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
);
const all = order.length + skipped.length;
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
}
go.addEventListener("click", () => void review());
go.addEventListener("click", () => void save());
void load();
}
@@ -1,21 +1,20 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
export const HOPYO_TITLE = "일위대가 실무자료";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
canWrite: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
@@ -42,22 +41,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
const upload = createButton({
label: "엑셀 올리기",
label: "파일 업로드",
variant: "filled",
size: "sm",
disabled: !opt.canWrite,
onClick: () => input.click(),
});
upload.classList.add("m02-layers__btn");
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
const ledger = el("button", {
className: "m02-side__item ui-sidebar-row",
text: LEDGER_TITLE,
attrs: {
type: "button",
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
"data-hopyo-ledger-open": "",
},
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
});
ledger.addEventListener("click", () => void showLedger());
const body = el("div", {
@@ -66,6 +62,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
});
let files: HopyoFile[] = [];
let open: { key: string; view: HTMLElement } | null = null;
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
let seq = 0;
input.addEventListener("change", () => {
@@ -78,9 +75,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await show(got);
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet");
sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite });
await showSheet(got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
@@ -92,32 +87,37 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
...(files.length
? files.map((f) => {
const row = el("button", {
className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`,
className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`,
text: `${f.파일} · 호표 ${f.호표수}`,
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
});
row.addEventListener("click", () => void show(f));
row.addEventListener("click", () => void showSheet(f));
return row;
})
: [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]),
);
}
async function show(file: HopyoFile): Promise<void> {
if (open?.key === file.키 && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view = buildHopyoView(file.키, {
scope,
canWrite: opt.canWrite,
onDeleted: () => {
view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" }));
void refresh();
},
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
async function showSheet(file: HopyoFile): Promise<void> {
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
showToast(why(error), "error");
return null;
});
opt.show(`${HOPYO_TITLE} — ${file.파일}`, view);
opt.onOpen();
open = { key: file.키, view };
if (!sheet) return;
sheetKey = file.키;
paintList();
sheet.openHopyoSheet({
scope,
key: file.키,
파일: file.파일,
canWrite: opt.canWrite,
onClose: () => {
if (sheetKey === file.키) sheetKey = null;
paintList();
},
onDeleted: () => void refresh(),
});
}
async function showLedger(): Promise<void> {
@@ -1,200 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_View.ts
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import {
deleteHopyo,
readHopyo,
type HopyoScope,
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoCheck, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import "./M02_MasterTemplete_Hopyo.css";
export interface HopyoViewOptions {
scope: HopyoScope;
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
canWrite: boolean;
/** 파일을 지웠음 — 좌측 목록 다시 */
onDeleted: () => void;
}
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");
/** 엑셀 보기 창(329) — 열 때 불러옴 */
const openSheet = (o: HopyoSheetOptions): void =>
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
const grid = (heads: string[], body: HTMLElement): HTMLElement =>
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",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
body,
],
}),
],
});
const td = (text: string, cls = ""): HTMLElement =>
el("td", { text, className: cls, attrs: { title: text } });
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
let nav = 0; // 늦게 온 응답 버림
let query = "";
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
const my = ++nav;
const stop = showLoading(view);
try {
const got = await get();
return my === nav ? got : null;
} catch (error) {
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
return null;
} finally {
stop();
}
}
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
async function drawFile(): Promise<void> {
const file = await load(
() => readHopyo(opt.scope, key),
() => void drawFile(),
);
if (!file) return;
let hopyos = file.호표;
const search = el("input", {
className: "ui-input ui-input--sm m02-hopyo__search",
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
}) as HTMLInputElement;
const body = el("tbody");
const paint = (): void => {
const q = search.value.trim().toLowerCase();
query = search.value;
body.replaceChildren(
...hopyos
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
.map((h) => {
const tr = el("tr", {
className: "m02-hopyo__row",
attrs: { "data-hopyo-no": String(h.번) },
children: [
td(`제${h.번}호표`),
td(h.이름),
td(h.규격),
td(h.단위),
td(won(h.금액.계), "m02-hopyo__num"),
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
],
});
tr.addEventListener("click", () => void drawOne(h.번));
return tr;
}),
);
};
search.addEventListener("input", paint);
paint();
// 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 갱신 — 셈 끝 · 다른 화면 · 못 읽음이면 멈춤
const ready = note("");
const calc = note("");
const show = (f: HopyoView): void => {
ready.textContent = `제안 ${f.맞춤[0]} / 줄 ${f.맞춤[1]}`;
const c = f.셈 && !f.셈.끝 ? f.셈 : null;
calc.textContent = c
? `숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`
: "";
calc.hidden = !c;
};
const my = nav;
const live = (): boolean => my === nav && view.isConnected;
const poll = (f: HopyoView): void => {
if (!f.셈 || f.셈.끝) return;
window.setTimeout(async () => {
const next = live() ? await readHopyo(opt.scope, key).catch(() => null) : null;
if (!next || !live()) return;
hopyos = next.호표;
show(next);
paint();
poll(next);
}, POLL_MS);
};
show(file);
poll(file);
const wrong = file.검산.어긋남;
view.replaceChildren(
el("div", {
className: "m02-hopyo__bar",
children: [
el("strong", { text: file.파일 }),
note(`시트 ${file.시트}`),
note(`호표 ${file.호표수}`),
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
ready,
calc,
createButton({
label: "엑셀로 보기",
size: "sm",
onClick: () =>
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
}),
...(opt.canWrite ? [dropButton(file)] : []),
],
}),
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
search,
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
);
}
const dropButton = (file: HopyoView): HTMLButtonElement =>
createButton({
label: "지우기",
variant: "danger",
size: "sm",
onClick: async () => {
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
if (!(await showConfirmDialog(ask, "지우기"))) return;
try {
await deleteHopyo(opt.scope, key);
showToast("지움", "success");
opt.onDeleted();
} catch (error) {
fail(error);
}
},
});
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
function drawOne(no: number): void {
nav += 1; // 파일 표 늦은 응답 버림
view.replaceChildren(
buildHopyoCheck({
scope: opt.scope,
key,
no,
canWrite: opt.canWrite,
onBack: () => void drawFile(),
}),
);
}
void drawFile();
return view;
}
@@ -185,6 +185,12 @@ let out0 = null;
{ 번: 7, 아래: [8] },
{ 번: 8, 아래: [7] },
]),
linked: [
[2, 2],
[1, 2],
[0, 0],
[3, 2],
].map(sheet.allLinked),
},
};
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
+40 -21
View File
@@ -24,7 +24,6 @@ NEW = (
"Hopyo_Api",
"Hopyo_Pick",
"Hopyo_Side",
"Hopyo_View",
"Hopyo_Sheet",
"Hopyo_Check",
"Hopyo_Choose",
@@ -192,7 +191,9 @@ def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
def test_엑셀_보기_창_하위_호표_먼저():
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
got = _check()["sheet"]
assert got["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
assert got["linked"] == [True, False, False, False] # 다 연결만 녹색 · 0/0 색 없음(359-2)
def _pick() -> dict:
@@ -279,18 +280,27 @@ def test_옛_즉시_확정과_바로_만들기_없음():
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
check = texts["Hopyo_Check"]
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
assert "m02-hopyo-check:" in check and "POLL_MS" in check
sheet = texts["Hopyo_Sheet"]
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
# 359-2 — 펼침 표 · [확인 창 열기] · [이대로 n개 만들기] 없음 · [저장] = 물음 한 번 뒤 체크 줄로 만들기
for gone in ("확인 창 열기", "이대로", "review(", "확인하고 만들기"):
assert gone not in sheet, gone
save = sheet.split("async function save(")[1].split("async function makeAll(")[0]
assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save
assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save
assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
row = sheet.split("function paintList(")[1]
assert row.index("m02-hopyo-sheet__no") < row.index("editButton(h)") < row.index("__name")
assert 'linked.classList.toggle("is-linked", allLinked(h.맞춤));' in row
pick = texts["Hopyo_Pick"]
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
view = texts["Hopyo_View"]
assert "buildHopyoCheck(" in view and "choose(" not in view
assert not (M02 / "M02_MasterTemplete_Hopyo_View.ts").exists() # 본 칸 표 없음(359-1)
ledger = texts["Hopyo_Ledger"]
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
@@ -306,6 +316,13 @@ def test_좌측_판_자리와_권한():
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
assert "buildHopyoView" not in hopyo_side
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
@@ -332,16 +349,15 @@ def test_로직_추가_한_곳과_파일_규칙():
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert lazy in view and '"엑셀로 보기"' in view
assert "await showSheet(got);" in side # 올린 직후 자동
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
assert "단산행: 행" in check
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
assert lazy in pick
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
for text in (side, view, pick, check):
for text in (side, pick, check):
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
@@ -400,15 +416,18 @@ def test_확인_창_손질_339():
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet
assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet
assert '`맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`' in sheet
def test_본_칸_호표_목록_셈_끝까지_다시_읽음_339():
"""339 덧고침 2 — 본 칸 호표 표 「제안 n / 줄 m」 · 머리 「셈 중」 — 셈 중이면 3 초마다 다시 읽음."""
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert "poll(file);" in view and "poll(next);" in view and "POLL_MS" in view
assert "hopyos = next.호표;" in view and "...hopyos" in view
assert (
"const live = (): boolean => my === nav && view.isConnected;" in view
) # 호표 하나로 가면 멈춤
assert "숫자 판정 셈 중" in view
def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359():
"""339 덧고침 2 → 359-1 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」 · [삭제])은
엑셀 보기 창 머리 · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께)."""
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
head = sheet.split("function paintHead(")[1].split("const dropButton")[0]
for word in ("시트 ", "호표 ", "검산 어긋남", "검산 맞음", "제안 ", "숫자 판정 셈 중"):
assert word in head, word
assert "opt.canWrite && opt.onDeleted ? [dropButton()]" in head
drop = sheet.split("const dropButton =")[1].split("/** 목록 강조")[0]
assert 'label: "삭제"' in drop and 'showConfirmDialog(ask, "삭제")' in drop
assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted")
poll = sheet.split("function poll(")[1].split("async function load(")[0]
assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll
+1 -1
View File
@@ -1,5 +1,5 @@
"""일위대가 실무자료 이음매(PLAN 329 · 330) — 실제 서버 응답(북부청 격자 · 호표 맞춤)을
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet)에 먹임.
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 도우미는 고르기 창 `Hopyo_Choose`.