feat(718-1): 일위대가 불러오기 = 한시 마법사 — 결과 층 고름 · [끝내기] 때 올린 파일 지움 · 같은 엑셀 겹침 막음

- 좌측 「일위대가 실무자료」 → 「일위대가 불러오기」(locale M02_HopyoImport) · 옛 파일 목록 걷고 [불러오기] → 본 칸 마법사(새 M02_MasterTemplete_Hopyo_Wizard.ts)
- 마법사 = ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과 체크(일위대가 · 단가산출은 715-2 뒤) · 엑셀 고르기 · 작업 중 이어서 → ② 엑셀 보기 창(모두 고정으로 들여오기 · 구조물 조합으로 그대로) → ③ 끝내기
- 서버: 올림 지문(sha256) · 작업 중 같은 엑셀 = 이어서 · 끝낸 같은 엑셀 409 · 지우기 = 끝내기(HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 있으면 지문 기록 · 별칭 장부 남음) · HP 번 다시 안 씀 · 목록 = 지문 있는 것만(옛 HP · 붙여넣기 HP 파일 그대로 숨김)
- 시험: 새 지킴 test_718_1_hopyo_import(임시 storage) · 낡은 시험 고침 — 같은 엑셀 다시 올림이 새 키를 내던 가정(excel · grid · flow) · 붙여넣기 HP 목록 가정(705_2) · 좌측 판 글 대조(screen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 15:08:17 +09:00
co-authored by Claude Opus 5.5
parent 9827926a20
commit f49b55a3d8
11 changed files with 421 additions and 148 deletions
+46 -8
View File
@@ -4,10 +4,14 @@
파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######`
(층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`.
층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫.
한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는
파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json`
`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀).
"""
from __future__ import annotations
import hashlib
import json
import re
import threading
@@ -21,6 +25,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
DIRNAME = "실무자료"
MEMORY = "맞춤"
GRID = "격자"
DONE = "불러옴"
NO_GRID = "격자 없음 — 다시 올리면 보임"
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
@@ -58,16 +63,33 @@ def brief(doc: dict[str, Any]) -> dict[str, Any]:
}
def fingerprint(data: bytes) -> str:
"""올린 엑셀 지문 — 같은 파일 다시 올림을 알아봄."""
return hashlib.sha256(data).hexdigest()
def rows(folder: str | Path) -> list[dict[str, Any]]:
"""층 실무자료 목록(키 차례)."""
"""층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김."""
out = []
for path in sorted(_dir(folder).glob("HP-*.json")):
doc = _load(path)
if isinstance(doc, dict) and _KEY.match(path.stem):
out.append(brief(doc))
if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"):
out.append({**brief(doc), "지문": doc["지문"]})
return out
def done(folder: str | Path) -> dict[str, Any]:
"""끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`."""
doc = _load(_dir(folder) / f"{DONE}.json")
doc = doc if isinstance(doc, dict) else {}
marks = doc.get("지문")
return {"번": int(doc.get("번") or 0), "지문": marks if isinstance(marks, dict) else {}}
def _put_done(folder: str | Path, got: dict[str, Any]) -> None:
atomic_write_json(_dir(folder) / f"{DONE}.json", got)
def read(folder: str | Path, key: str) -> dict[str, Any]:
doc = _load(_path(folder, key))
if not isinstance(doc, dict):
@@ -81,25 +103,30 @@ def save(
parsed: dict[str, Any],
who: Any,
grid: dict[str, Any] | None = None,
mark: str | None = None,
) -> dict[str, Any]:
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄."""
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문."""
with _LOCK:
have = [
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
]
key = f"HP-{max(have, default=0) + 1:06d}"
log = done(folder)
log["번"] = max(*have, log["번"], 0) + 1
key = f"HP-{log['번']:06d}"
doc = {
"키": key,
"파일": name,
"올린때": utc_now_text(),
"올린이": who,
**({"지문": mark} if mark else {}),
**parsed,
}
if grid is not None:
book = {**grid["책"], "열": key}
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
atomic_write_json(_path(folder, key), doc)
return brief(doc)
_put_done(folder, log)
return {**brief(doc), **({"지문": mark} if mark else {})}
def grid(folder: str | Path, key: str) -> dict[str, Any]:
@@ -110,11 +137,22 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]:
return doc
def delete(folder: str | Path, key: str) -> None:
def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None:
"""HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음)."""
path = _path(folder, key)
with _LOCK:
if not path.is_file():
doc = _load(path)
if not isinstance(doc, dict):
raise store.StoreError(404, f"없는 실무자료 「{key}」")
if made and doc.get("지문"):
log = done(folder)
log["지문"][doc["지문"]] = {
"파일": doc.get("파일"),
"키": key,
"끝낸때": utc_now_text(),
"만든": made,
}
_put_done(folder, log)
path.unlink()
_grid_path(folder, key).unlink(missing_ok=True)
+35 -5
View File
@@ -6,7 +6,9 @@
사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) +
일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) ·
338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 ·
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`).
올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) ·
718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) ·
지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음).
층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌).
⚠ 등록은 `main.py`(로그인만).
"""
@@ -198,6 +200,16 @@ def _who(session: Session) -> dict[str, Any]:
def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
mark = hopyo_store.fingerprint(data)
if old := hopyo_store.done(store.root())["지문"].get(mark):
made = len(old.get("만든") or [])
when = str(old.get("끝낸때") or "")[:10]
raise store.StoreError(
409,
f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음",
)
if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None):
return {**same, "이어서": True}
try:
parsed = hopyo_excel.read_book(name, data)
except hopyo_excel.ExcelError as error:
@@ -206,7 +218,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
grid = hopyo_grid.read(name, data, parsed)
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
row = hopyo_store.save(store.root(), name, parsed, who, grid)
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
return row
@@ -441,10 +453,28 @@ def hopyo_ledger_write(
def hopyo_delete(
layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session)
) -> dict:
"""실무자료 지우기(만든 사전조합 · 맞춤 기억은 그대로)."""
"""실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가
(이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) ·
별칭 장부 그대로 → `{지움, 만든}`."""
folder = _root(session, layer, owner, write=True)
_call(folder, hopyo_store.delete, folder, key)
return {"지움": key}
return _call(folder, _finish, folder, key)
def _finish(folder: Path, key: str) -> dict[str, Any]:
head = f"{key} 제"
made = sorted(
{sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)}
)
if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None:
try:
got = cache_path(folder, key)
except (OSError, store.StoreError):
got = None
# ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠
for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []:
old.unlink(missing_ok=True)
hopyo_store.delete(folder, key, made)
return {"지움": key, "만든": len(made)}
class PasteBody(_Strict):
@@ -1,21 +1,19 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Side.ts
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) →
* 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) ·
* 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) ·
* 아래 [별칭 장부](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 { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { pageScope } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
import { createEmpty } from "@ui/ui_template_empty";
export const HOPYO_TITLE = "일위대가 실무자료";
import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
/** 이 층 쓰기 권한 — 별칭 장부 고침 */
canWrite: boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
@@ -30,122 +28,48 @@ export interface HopyoSection {
body: HTMLElement;
/** 다른 화면이 열렸으면 고름 표시를 지움 */
paint: () => void;
refresh: () => Promise<void>;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const WIZARD = "불러오기";
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const title = L("M02_HopyoImport");
const scope = pageScope();
const list = el("div", { className: "m02-side__items ui-sidebar-list" });
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
const upload = createButton({
label: "파일 업로드",
const start = createButton({
label: "불러오기",
variant: "filled",
disabled: !opt.canWrite,
onClick: () => input.click(),
onClick: () => void showView(WIZARD),
});
upload.classList.add("m02-layers__btn");
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
start.classList.add("m02-layers__btn");
start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const ledger = el("button", {
className: "m02-side__item ui-sidebar-row",
text: LEDGER_TITLE,
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
});
ledger.addEventListener("click", () => void showLedger());
ledger.addEventListener("click", () => void showView(LEDGER_TITLE));
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
children: [el("div", { className: "m02-side__row", children: [start] }), ledger],
});
let files: HopyoFile[] = [];
let open: { key: string; view: HTMLElement } | null = null;
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
let seq = 0;
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scope, file)
.then(async (got) => {
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
await refresh();
await showSheet(got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !opt.canWrite));
});
function paintList(): void {
const paintList = (): void => {
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
list.replaceChildren(
...(files.length
? files.map((f) => {
const row = el("button", {
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 showSheet(f));
return row;
})
: [createEmpty({ text: "올린 실무자료 없음", small: true })]),
);
}
};
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
async function showSheet(file: HopyoFile): Promise<void> {
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
showToast(why(error), "error");
return null;
});
if (!sheet) return;
sheetKey = file.키;
paintList();
sheet.openHopyoSheet({
scope,
key: file.키,
파일: file.파일,
// 사전조합은 어느 화면 층에서든 로그인한 사람 개인 층에 만듦 — 회사 · 마스터는 구조물 카드 [가져오기]
target: { layer: "personal", owner: null },
canWrite: true,
onClose: () => {
if (sheetKey === file.키) sheetKey = null;
paintList();
},
// [삭제]는 실무자료 파일이 있는 화면 층 쓰기 권한 때만
onDeleted: opt.canWrite ? () => void refresh() : undefined,
});
}
async function showLedger(): Promise<void> {
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
async function showView(key: string): Promise<void> {
if (open?.key === key && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
const view =
key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite });
opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view);
opt.onOpen();
open = { key: LEDGER_TITLE, view };
open = { key, view };
paintList();
}
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await listHopyo(scope);
if (mine === seq) files = got;
} catch (error) {
if (mine === seq) files = [];
showToast(why(error), "error");
}
if (mine === seq) paintList();
}
const paint = (): void => {
if (open && !open.view.isConnected) {
open = null;
@@ -153,6 +77,5 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
}
};
void refresh();
return { title: HOPYO_TITLE, body, paint, refresh };
return { title, body, paint };
}
@@ -0,0 +1,206 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Wizard.ts
* 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋:
* ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림
* (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서
* ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] →
* 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층
* ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록
* 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createEmpty } from "@ui/ui_template_empty";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
deleteHopyo,
listHopyo,
uploadHopyo,
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
const LAYERS: { layer: ManageLayer; text: string }[] = [
{ layer: "personal", text: "개인" },
{ layer: "company", text: "회사" },
{ layer: "system", text: "마스터" },
];
const STEPS = [
{ value: "1", label: "① 층 · 엑셀" },
{ value: "2", label: "② 보기 · 확인 · 만들기" },
{ value: "3", label: "③ 끝내기" },
];
/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */
export const SANGEUN_READY = false;
const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬";
type Got = HopyoFile & { 이어서?: boolean };
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const textOf = (layer: string): string => LAYERS.find((x) => x.layer === layer)?.text ?? layer;
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
export async function writableLayers(): Promise<ManageLayer[]> {
const user = await fetchSessionUser().catch(() => null);
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
}
export function buildHopyoWizard(): HTMLElement {
const steps = createSegment({
options: STEPS.map((s) => ({ ...s, disabled: true })),
value: "1",
});
steps.root.dataset.hopyoSteps = "";
const body = el("div", { className: "m02-hopyo" });
const root = el("div", {
className: "m02-hopyo",
attrs: { "data-hopyo-wizard": "" },
children: [
createText(
"한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음",
"hint",
),
el("div", { className: "ui-toolbar", children: [steps.root] }),
body,
],
});
let layers: ManageLayer[] = [];
let pick: ManageLayer = "personal";
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
async function first(): Promise<void> {
steps.set("1");
layers = await writableLayers();
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
}) as HTMLInputElement;
input.hidden = true;
const upload = createButton({
label: "엑셀 고르기",
variant: "filled",
disabled: !layers.length,
onClick: () => input.click(),
});
upload.title = layers.length ? "설계내역서 엑셀(일위대가 시트)" : "쓸 수 있는 층 없음";
input.addEventListener("change", () => {
const file = input.files?.[0];
input.value = "";
if (!file) return;
upload.disabled = true;
uploadHopyo(scopeOf(pick), file)
.then((got: Got) => {
if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
second(pick, got);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (upload.disabled = !layers.length));
});
const seg = createSegment({
options: LAYERS.map((x) => ({
value: x.layer,
label: x.text,
disabled: !layers.includes(x.layer),
})),
value: pick,
onChange: (v) => (pick = v as ManageLayer),
});
seg.root.dataset.hopyoLayer = "";
const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true });
const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY });
sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY;
const busy = el("div", { className: "m02-hopyo" });
body.replaceChildren(
createText("결과 층", "muted", 14),
el("div", { className: "ui-toolbar", children: [seg.root] }),
createText("결과", "muted", 14),
el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }),
...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]),
el("div", { className: "ui-toolbar", children: [upload, input] }),
createPanelHead({ title: "작업 중", level: 4, divider: true }).root,
busy,
);
const rows = (
await Promise.all(
layers.map((layer) =>
listHopyo(scopeOf(layer))
.then((list) => list.map((f) => ({ layer, f })))
.catch(() => []),
),
)
).flat();
busy.replaceChildren(
...(rows.length
? rows.map(({ layer, f }) => {
const row = createButton({
label: `${f.파일} · 호표 ${f.호표수} · ${textOf(layer)} — 이어서`,
variant: "ghost",
onClick: () => second(layer, f),
});
row.dataset.hopyoKey = f.키;
return row;
})
: [createEmpty({ text: "작업 중인 불러오기 없음", small: true })]),
);
}
function second(layer: ManageLayer, file: HopyoFile): void {
steps.set("2");
const scope = scopeOf(layer);
const view = createButton({
label: "엑셀 보기 · 확인 · 만들기",
variant: "filled",
onClick: () =>
void import("./M02_MasterTemplete_Hopyo_Sheet")
.then((m) =>
m.openHopyoSheet({
scope,
key: file.키,
파일: file.파일,
target: scope,
canWrite: true,
onDeleted: () => void first(),
}),
)
.catch((error: unknown) => showToast(why(error), "error")),
});
const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() });
finish.dataset.hopyoFinish = "";
const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() });
back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서";
const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : "";
body.replaceChildren(
createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14),
createText(
"엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감",
"hint",
),
el("div", { className: "ui-toolbar", children: [view, finish, back] }),
);
async function end(): Promise<void> {
steps.set("3");
const ok = await showConfirmDialog(
`「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`,
"끝내기",
);
if (!ok) return steps.set("2");
try {
const got = (await deleteHopyo(scope, file.키)) as { 만든?: number };
showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success");
} catch (error) {
showToast(why(error), "error");
return steps.set("2");
}
await first();
}
}
void first();
return root;
}