diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index b5ae56968..9c4632c22 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -273,6 +273,58 @@ } } +/* 불러오기 본 칸(785-4) — 본 칸을 화면 높이로 채움(도면 양식 앞선 꼴 한 벌) · 시트 아래 줄 판 자리 */ +.m02-main:has(> .m02-main__host > .m02-hopyo-import) { + box-sizing: border-box; + height: calc(100dvh - var(--app-header-h)); + min-height: 0; +} + +.m02-main__host:has(> .m02-hopyo-import) { + display: flex; + flex: 1 1 0; + min-height: 0; +} + +.m02-hopyo-import { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: var(--spacing-8); + width: 100%; + min-width: 0; + min-height: 0; +} + +.m02-hopyo-import > .m02-hopyo-sheet { + flex: 1 1 0; +} + +.m02-hopyo-sheet__col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + min-height: 0; +} + +.m02-hopyo-sheet__col > .m02-hopyo-sheet__grid { + flex: 1 1 0; + min-height: 8rem; +} + +.m02-hopyo-sheet__lines { + flex: 0 1 45%; + min-height: 0; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-hopyo-sheet__lines[hidden] { + display: none; +} + /* 세 보기 막대 · 범례(779-4) — 시트 위 한 줄씩 · 넘치면 감김 */ .m02-hopyo-sheet__nav { display: flex; @@ -300,7 +352,7 @@ /* 단가산출 탭 오른쪽 = 단가산출 목록(780-2) — 판 넓힘 · 숨김이 display 에 안 덮이게 · 강조 줄 */ @media (min-width: 901px) { .m02-hopyo-sheet.is-sk { - grid-template-columns: minmax(0, 1fr) minmax(22rem, 42%); + grid-template-columns: minmax(0, 1fr) minmax(24rem, 44%); } } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts new file mode 100644 index 000000000..e5360760f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Import.ts + * 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) — + * 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기: + * · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 제안)의 머리에 층 · 안 이은 줄 배지 · + * [안 이은 줄 고르기] · [끝내기]를 덧붙임 + * · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 목록 아래 막대 + * · 안 이은 줄 수 = 엑셀 보기가 읽은 호표 번으로 셈([끝내기] 전 「0」 오표시 흠 고침 — 빈 호표 번 안 씀) + * · [끝내기] = 남은 줄 다시 셈 → 있으면 막음(배지 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · + * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 + * · 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 · + * 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림) + * ========================================================================== */ + +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + deleteHopyo, + readHopyo, + type HopyoFile, + type HopyoScope, + type HopyoView, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { draftKey, hasDraft } from "./M02_MasterTemplete_Hopyo_Check"; +import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet"; +import { + countLeft, + leftCount, + openLeft, + type LeftMap, + type LeftScope, +} from "./M02_MasterTemplete_Hopyo_Left"; +import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; +import "./M02_MasterTemplete_Hopyo.css"; + +export const LAYERS: { layer: ManageLayer; text: string }[] = [ + { layer: "personal", text: "개인" }, + { layer: "company", text: "회사" }, + { layer: "system", text: "마스터" }, +]; +export const layerText = (layer: string): string => + LAYERS.find((x) => x.layer === layer)?.text ?? layer; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */ +export async function writableLayers(): Promise { + const user = await fetchSessionUser().catch(() => null); + return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite); +} + +export interface HopyoImportArgs { + layer: ManageLayer; + file: HopyoFile; + /** [끝내기] 로 올린 파일을 지웠음 */ + onEnded: () => void; +} + +export interface HopyoImport { + root: HTMLElement; + /** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */ + leave: () => void; + /** 고른 뒤 안 만든 확인 창 캐시가 있음 — 다른 엑셀로 옮기기 전 물음 */ + dirty: () => boolean; +} + +export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { + const { layer, file } = a; + const scope: HopyoScope = { layer, owner: null }; + const at: LeftScope = { scope, target: scope, key: file.키 }; + let nos: number[] = []; + let map: LeftMap | null = null; + let held = true; + let body: HopyoSheetBody | null = null; + + const root = el("div", { + className: "m02-hopyo-import", + attrs: { "data-hopyo-import": file.키 }, + children: [createText("엑셀을 읽는 중…", "muted", 14)], + }); + const leave = (): void => { + window.removeEventListener("pagehide", leave); + if (!held) return; + held = false; + void deleteHopyo(scope, file.키, true).catch(() => {}); + body?.destroy(); + }; + window.addEventListener("pagehide", leave); + + // ── 머리 막대에 덧붙일 것 ── + const badge = el("span", { attrs: { "data-hopyo-left": "" } }); + const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore + finish.dataset.hopyoFinish = ""; + const onSk = (번: number): void => body?.showSk(번); + const pickBtn = createButton({ + label: L("M02_LeftOpen"), + variant: "ghost", + size: 28, + onClick: () => void openPick(), + }); + pickBtn.dataset.hopyoLeftOpen = ""; + const layerNote = createBadge(`${layerText(layer)} 층`, "neutral"); + + /** 안 이은 줄 창 — 호표 번이 아직 없으면 먼저 읽음(빈 번으로 열면 「0」 오표시) */ + async function openPick(): Promise { + try { + await ensureNos(); + } catch (error) { + return void showToast(why(error), "error"); + } + openLeft({ ...at, 파일: file.파일, nos, onSk, ...(map ? { map } : {}) }, paint); + } + + async function ensureNos(): Promise { + if (!nos.length) nos = (await readHopyo(scope, file.키)).호표.map((h) => h.번); + } + + /** 배지 — 남은 줄 있으면 [끝내기] 막음 · 까닭 · 남은 수 */ + function paint(got: LeftMap | null): void { + map = got; + const n = got ? leftCount(got) : null; + const w = body?.skCount(); + const skLeft = w?.블록 ? `${L("M02_SkLeft").replace("{n}", String(w.블록)).replace("{m}", String(w.줄))} · ` : ""; // prettier-ignore + finish.disabled = !!n?.줄; + finish.title = n?.줄 ? skLeft + L("M02_LeftBlocked") : ""; + badge.dataset.left = n ? String(n.줄) : ""; + const tone: BadgeTone = !n ? "info" : n.줄 ? "warning" : "success"; + const text = !n + ? L("M02_LeftUnknown") + : n.줄 + ? L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표)) + : L("M02_LeftZero"); + badge.replaceChildren(createBadge(text, tone)); + } + paint(null); + + async function recount(): Promise { + await ensureNos(); + const got = await countLeft(at, nos, (i, all) => + badge.replaceChildren(createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14)), + ); + paint(got); + return got; + } + + async function end(): Promise { + finish.disabled = true; + let got: LeftMap; + try { + got = await recount(); + } catch (error) { + showToast(why(error), "error"); + finish.disabled = false; + return; + } + if (leftCount(got).줄) { + showToast(L("M02_LeftBlocked"), "warning"); + return openLeft({ ...at, 파일: file.파일, nos, onSk, map: got }, paint); + } + const loose = [...got.values()].filter( + (x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)), + ).length; + const w = body?.skCount(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만 + const ok = await showConfirmDialog( + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` + + (loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : "") + + (w?.블록 + ? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}` + : ""), // 780-2 막지 않음 + "끝내기", + ); + if (!ok) return paint(map); + try { + const out = (await deleteHopyo(scope, file.키)) as { 만든?: number }; + held = false; + window.removeEventListener("pagehide", leave); + body?.destroy(); + showToast(`끝냄 — 만든 일위대가 · 단가산출 ${out.만든 ?? 0} · 올린 파일 지움`, "success"); + } catch (error) { + showToast(why(error), "error"); + return paint(map); + } + a.onEnded(); + } + + // ── 몸 — 엑셀 보기 몸을 바로 담음(스프레드시트는 열 때 불러옴) ── + void import("./M02_MasterTemplete_Hopyo_Sheet") + .then((m) => { + if (!held) return; + const view = m.buildHopyoSheet({ + scope, + key: file.키, + 파일: file.파일, + target: scope, + canWrite: true, + headExtra: [layerNote, badge, pickBtn, finish], + onSkChange: () => paint(map), + onRead: (got: HopyoView) => void (nos = got.호표.map((h) => h.번)), + }); + body = view; + root.replaceChildren(...view.parts); + }) + .catch((error: unknown) => root.replaceChildren(createText(why(error), "muted", 14))); + + return { + root, + leave, + dirty: () => nos.some((no) => hasDraft(draftKey(scope, file.키, no))), + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index 15b01750a..577d3a4d6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -1,67 +1,42 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts - * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 · - * 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) · + * 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 · + * 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m · 숫자 판정 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · 안 이은 줄 · [끝내기]) · * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 · - * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 창은 다시 씀 = 앞으로 꺼내 그 자리로 · 779-6) · + * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 몸은 다시 씀 = 그 자리로 뜀 · 779-6) · * 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장] · * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 - * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로) - * · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 - * 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] · - * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) - * · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 · - * 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음 + * · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로 · 785-6 이 줄 판으로 갈음) + * · [저장] · [모두 고정으로 들여오기] = `Hopyo_SheetMake` * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨 + * · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · `openHopyoSheet` = 모달 껍데기([로직 추가] 「실무 호표」 길 — 785-7 이 지움) * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ -import { - createButton, - el, - hideLoadingOverlay, - showConfirmDialog, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createInputField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; -import { t as L } from "@ui/ui_template_locale"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { - buildHopyo, - buildHopyoAll, - deleteHopyo, readHopyo, - readHopyoOne, type HopyoBrief, type HopyoBuilt, - type HopyoBuiltAll, type HopyoGrid, - type HopyoOne, - type HopyoScope, type HopyoSpan, type HopyoView, + type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; -import { - draftKey, - hasDraft, - HOPYO_BUILT, - loadDraft, - openHopyoCheck, - partsOf, - POLL_MS, -} from "./M02_MasterTemplete_Hopyo_Check"; -import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids"; -import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; +import { openHopyoCheck, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; -import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; -import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; -import { formatPrice } from "../common_util/common_util_money"; +import { note, why, wireMake } from "./M02_MasterTemplete_Hopyo_SheetMake"; import "./M02_MasterTemplete_Hopyo.css"; +export { buildOrder } from "./M02_MasterTemplete_Hopyo_SheetMake"; + export interface HopyoSheetOptions { /** 실무자료 파일 층 */ scope: HopyoScope; @@ -79,20 +54,33 @@ export interface HopyoSheetOptions { onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; /** 창을 닫았음(좌측 파일 줄 고름 표시) */ onClose?: () => void; - /** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */ - onDeleted?: () => void; + /** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */ + headExtra?: HTMLElement[]; + /** 단가산출 판정 · 옮김 뒤 */ + onSkChange?: () => void; + /** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */ + onRead?: (file: HopyoView) => void; +} + +/** 몸 — 머리 막대 · 어긋남 글 · 시트 + 목록(세로로 쌓는 자리에 이어 붙임) */ +export interface HopyoSheetBody { + parts: HTMLElement[]; + /** 몸을 걷음(마지막 창 손잡이 · 격자 · 스프레드시트) */ + destroy: () => void; + /** 모달에서 앞으로 꺼낸 몸을 제자리로 — 했으면 참(닫기 대신) */ + lower: () => boolean; + /** 안 옮긴 단가산출 블록 수(판정 전 null) */ + skCount: () => { 블록: number; 밖: number; 줄: number } | null; + /** 그 단가산출 블록으로 뜀 + 줄 판 엶 */ + showSk: (번: number) => void; } const ILWI = "일위대가"; const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate · 역할 id 없는 옛 격자) /** 열린 창 손잡이(779-6) — 파일마다 · 다시 부르면 그 창이 앞으로 와 그 자리로 뜀 */ const opened = new Map void>(); - -const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); -const won = (v: number | null | undefined): string => - v === null || v === undefined ? "" : formatPrice(v); -const note = (text: string): HTMLElement => - el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); +const sheetId = (o: HopyoSheetOptions): string => + `${o.scope.layer}:${o.scope.owner ?? ""}:${o.key}`; /** 목록 숫자 「연결된 줄 / 연결할 줄」(358-3 두 칸 · 없으면 옛 맞춤 [제안, 줄]) — 다 연결(같고 0 넘음)이면 참 · * 0/0 은 색 없음 */ @@ -126,26 +114,31 @@ export function lastColumn(sheet: Pick): string { 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; -} - /** 단가산출 행 → B ~ F 열 범위 A1(`B4:F9`) */ export const rowsText = (행: number | [number, number]): string => typeof 행 === "number" ? `B${행}:F${행}` : `B${행[0]}:F${행[1]}`; +/** 모달 껍데기 — 같은 파일 몸이 이미 있으면(본 칸 · 모달) 그 몸을 그 자리로 뜀 */ export function openHopyoSheet(opt: HopyoSheetOptions): void { - const id = `${opt.scope.layer}:${opt.scope.owner ?? ""}:${opt.key}`; + const id = sheetId(opt); if (opened.has(id)) return opened.get(id)!(opt); + const view = buildHopyoSheet(opt); + openModal({ + title: `엑셀 보기 — ${opt.파일}`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo-sheet__dialog", + beforeClose: async () => { + if (view.lower()) return false; // 꺼낸 창 = 닫기 대신 제자리로 + view.destroy(); + return true; + }, + mount: (body) => body.append(...view.parts), + }); +} + +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) @@ -175,10 +168,19 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { className: "m02-hopyo-sheet__grid", children: [note("엑셀을 읽는 중…")], }); - const search = el("input", { - className: "ui-input ui-input--sm", - attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" }, - }) as HTMLInputElement; + const lines = el("div", { + className: "m02-hopyo-sheet__lines", + attrs: { "data-hopyo-lines": "" }, + }); // 시트 아래 줄 판 자리 + lines.hidden = true; + 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 go = createButton({ label: "저장", variant: "filled", size: "sm" }); @@ -191,7 +193,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const right = el("div", { className: "m02-hopyo-sheet__side", children: [ - search, + searchField.root, el("div", { className: "ui-toolbar m02-hopyo__bar", children: [count, ...(opt.canWrite ? [fixAll, go] : [])], @@ -209,19 +211,6 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { sheet?.destroy(); opt.onClose?.(); }; - const modal = openModal({ - title: `엑셀 보기 — ${opt.파일}`, - closeLabel: "닫기", - wide: true, - dialogClass: "m02-modal m02-hopyo-sheet__dialog", - beforeClose: async () => { - if (Nav.lower(left, above.splice(0))) return false; // 꺼낸 창 = 닫기 대신 제자리로 - finish(); - return true; - }, - mount: (body) => - body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })), - }); const paintGo = (): void => { go.querySelector(".ui-btn__label")!.textContent = checked.size @@ -230,7 +219,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { go.disabled = !checked.size; }; - /** 머리 막대 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제]) */ + /** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */ function paintHead(file: HopyoView): void { const wrong = file.검산.어긋남; const c = file.셈 && !file.셈.끝 ? file.셈 : null; @@ -242,33 +231,13 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ...(c ? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)] : []), - ...(opt.canWrite && opt.onDeleted ? [dropButton()] : []), + ...(opt.headExtra ?? []), ); + opt.onRead?.(file); warn.textContent = wrong.join("\n"); warn.hidden = !wrong.length; } - const dropButton = (): HTMLButtonElement => - createButton({ - label: "삭제", - variant: "danger", - size: "sm", - onClick: async () => { - const ask = `실무자료 「${opt.파일}」을 지움 — 만든 ${L("M02_PrecomboTitle")} · 별칭 장부는 그대로`; - if (!(await showConfirmDialog(ask, "삭제"))) return; - try { - await deleteHopyo(opt.scope, opt.key); - showToast("지움", "success"); - Nav.dropGrid(); - 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"); @@ -287,7 +256,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); } opened.set(id, (o) => { - above = Nav.raise(left); + above = Nav.raise(left); // 모달이 아니면 [] — 그 자리로만 뜀 if (o.단산행 !== undefined && detail) nav?.jumpTo(detail, o.단산행); else if (o.호표 !== undefined) focus(o.호표); }); @@ -322,22 +291,23 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ); 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 box = createCheckbox("", { + checked: checked.has(h.번), + onChange: (on) => { + if (on) checked.add(h.번); + else checked.delete(h.번); + paintGo(); + }, }); + box.root.hidden = !opt.canWrite; + box.root.addEventListener("click", (e) => e.stopPropagation()); 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: [ - box, + box.root, el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), editButton(h), el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), @@ -351,7 +321,6 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { ); count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`; } - search.addEventListener("input", paintList); async function mountSheet(grid: HopyoGrid): Promise { const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); @@ -372,7 +341,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left }; nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; if (nav) warn.after(nav.root); - sk = nav ? createSheetSk({ ...opt, target, nav, side: right }) : null; + sk = nav ? createSheetSk({ ...opt, target, nav, side: right, lines, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore sheet = createSpreadsheet(left, book, { readOnly: true, 칠: nav?.fill, @@ -435,180 +404,26 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } } - 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 }), - ], - }); - - const body = (one: HopyoOne) => bodyPair(opt.scope, opt.key, one); - const headCell = (no: number, name: string): HTMLElement => - el("td", { text: `제${no}호표 ${name}`.trim() }); - - /** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */ - async function save(): Promise { - 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}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` + - (fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : ""); - if (!(await showConfirmDialog(ask, "저장"))) return; - go.disabled = true; - const ones: HopyoOne[] = []; - 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) })] })); - } - } - ones.push(...(await unmadeKids(opt.scope, opt.key, target, ones))); // 777-1 하위 함께 - } finally { - hideLoadingOverlay(); - paintGo(); - } - const ready = ones.filter((one) => readyFor(opt.scope, opt.key, one, nos)); // 하위 함께 - for (const one of ones) - if (!ready.includes(one)) - skipped.push( - el("tr", { - children: [ - headCell(one.호표.번, one.호표.이름), - el("td", { text: "건너뜀 — 체크 줄 없음" }), - ], - }), - ); - await makeAll(ready, skipped); - } - - /** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */ - async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise { - 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 cell = headCell(no, h.이름); - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; - const got = await buildHopyo(target, from, ...body(one)); - ok += 1; - opt.onImport?.(got, h); - rows.push( - el("tr", { - attrs: { "data-built": got.참조.키 }, - children: [ - cell, - el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }), - el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), - el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), - ], - }), - ); - } catch (error) { - const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄 - const left = leftButton(err, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: kid }); // prettier-ignore - const text = `실패 — ${why(error)}`; - rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore - } - } - } finally { - hideLoadingOverlay(); - } - if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); - result.replaceChildren( - table( - ["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"], - [...rows, ...skipped], - ), - ); - const all = order.length + skipped.length; - showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning"); - } - go.addEventListener("click", () => void save()); - - /** 들여오기 알림 글(705-3) */ - const allText = (got: HopyoBuiltAll): string => - `호표 ${got.호표} · 줄 ${got.줄} · 품셈 줄 ${got.품셈} · 고정 줄 ${got.고정} · ` + - `하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐} · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` + - (got.그대로 ? ` · 이미 만든 호표 ${got.그대로} 그대로` : ""); - - /** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */ - async function importAll(): Promise { - const ask = - `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` + - "이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]"; - if (!(await showConfirmDialog(ask, "들여오기"))) return; - fixAll.disabled = true; - showLoadingOverlay(); - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key }; - const got = await buildHopyoAll(target, from); - if (got.호표 > got.그대로) document.dispatchEvent(new Event(HOPYO_BUILT)); - const next = - "다음 — 호표 [수정]에서 [품셈으로 바꾸기]" + - (got.이음후보 - ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈` - : ""); - const rows = [ - ...got.다름.map((x) => - el("tr", { - children: [ - el("td", { text: `제${x.번}호표 ${x.이름}` }), - el("td", { - text: `엑셀 ${won(x.엑셀)} · 우리 ${won(x.우리)}`, - className: "m02-hopyo__num", - }), - el("td", { text: won(x.차이), className: "m02-hopyo__num" }), - ], - }), - ), - ...got.실패.map((x) => { - const left = leftButton({ detail: x }, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: x.번 }); // prettier-ignore - return el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 }), ...(left ? [el("td", { children: [left] })] : [])] }); // prettier-ignore - }), - ]; - const relink = createButton({ - label: "다음 — 이 공사 단가로 다시 잇기", - size: "sm", - onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))), - }); // 705-2 - result.replaceChildren( - note(allText(got)), - note(next), - relink, - ...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []), - ); - showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success"); - } catch (error) { - showToast(`실패 — ${why(error)}`, "error"); - } finally { - hideLoadingOverlay(); - fixAll.disabled = false; - } - } - fixAll.addEventListener("click", () => void importAll()); - + wireMake({ + scope: opt.scope, + key: opt.key, + 파일: opt.파일, + target, + ...(opt.onImport ? { onImport: opt.onImport } : {}), + checked, + briefs: () => briefs, + closed: () => closed, + go, + fixAll, + result, + paintGo, + }); void load(); + return { + parts: [head, warn, el("div", { className: "m02-hopyo-sheet", children: [col, right] })], + destroy: finish, + lower: () => Nav.lower(left, above.splice(0)), + skCount: () => sk?.count() ?? null, + showSk: (번) => sk?.show(번), + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetMake.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetMake.ts new file mode 100644 index 000000000..0723e54b1 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetMake.ts @@ -0,0 +1,256 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_SheetMake.ts + * 엑셀 보기 몸의 만들기 셋(PLAN 785-4 · `Hopyo_Sheet` 에서 옮김 — 줄 바꿈 없음) — + * · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 + * 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] · + * 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 · + * 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음 + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + buildHopyo, + buildHopyoAll, + readHopyoOne, + type HopyoBrief, + type HopyoBuilt, + type HopyoBuiltAll, + type HopyoOne, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { + draftKey, + hasDraft, + HOPYO_BUILT, + loadDraft, + partsOf, +} from "./M02_MasterTemplete_Hopyo_Check"; +import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; +import { leftButton } from "./M02_MasterTemplete_Hopyo_Left"; +import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink"; +import { formatPrice } from "../common_util/common_util_money"; + +export const why = (error: unknown): string => + error instanceof Error ? error.message : String(error); +export const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : formatPrice(v); +export const note = (text: string): HTMLElement => + el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); + +/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */ +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; +} + +export interface MakeContext { + scope: HopyoScope; + key: string; + 파일: string; + /** 만들 층 */ + target: HopyoScope; + onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; + checked: Set; + briefs: () => HopyoBrief[]; + closed: () => boolean; + go: HTMLButtonElement; + fixAll: HTMLButtonElement; + result: HTMLElement; + paintGo: () => void; +} + +/** [저장] · [모두 고정으로 들여오기] 누름을 이음 */ +export function wireMake(c: MakeContext): void { + const { scope, key, target, go, fixAll, result } = c; + 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 }), + ], + }); + + const body = (one: HopyoOne) => bodyPair(scope, key, one); + const headCell = (no: number, name: string): HTMLElement => + el("td", { text: `제${no}호표 ${name}`.trim() }); + + /** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */ + async function save(): Promise { + const nos = [...c.checked].sort((a, b) => a - b); + const fresh = nos.filter((no) => !hasDraft(draftKey(scope, key, no))).length; + const ask = + `고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` + + (fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : ""); + if (!(await showConfirmDialog(ask, "저장"))) return; + go.disabled = true; + const ones: HopyoOne[] = []; + const skipped: HTMLElement[] = []; + showLoadingOverlay(); + try { + for (const no of nos) { + if (c.closed()) return; + try { + ones.push(await readHopyoOne(scope, key, no, target)); + } catch (error) { + skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] })); + } + } + ones.push(...(await unmadeKids(scope, key, target, ones))); // 777-1 하위 함께 + } finally { + hideLoadingOverlay(); + c.paintGo(); + } + const ready = ones.filter((one) => readyFor(scope, key, one, nos)); // 하위 함께 + for (const one of ones) + if (!ready.includes(one)) + skipped.push( + el("tr", { + children: [ + headCell(one.호표.번, one.호표.이름), + el("td", { text: "건너뜀 — 체크 줄 없음" }), + ], + }), + ); + await makeAll(ready, skipped); + } + + /** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */ + async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise { + const by = new Map(ones.map((one) => [one.호표.번, one])); + const order = buildOrder( + ones.map((one) => { + const d = loadDraft(draftKey(scope, 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 = c.briefs().find((x) => x.번 === no) ?? one.호표; + const cell = headCell(no, h.이름); + try { + const from = { 층: scope.layer, 주인: scope.owner, 키: key, 번: no }; + const got = await buildHopyo(target, from, ...body(one)); + ok += 1; + c.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + cell, + el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }), + el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), + el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), + ], + }), + ); + } catch (error) { + const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄 + const left = leftButton(err, { scope, target, key, 파일: c.파일, 번: kid }); // prettier-ignore + const text = `실패 — ${why(error)}`; + rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore + } + } + } finally { + hideLoadingOverlay(); + } + if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); + result.replaceChildren( + table( + ["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"], + [...rows, ...skipped], + ), + ); + const all = order.length + skipped.length; + showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning"); + } + go.addEventListener("click", () => void save()); + + /** 들여오기 알림 글(705-3) */ + const allText = (got: HopyoBuiltAll): string => + `호표 ${got.호표} · 줄 ${got.줄} · 품셈 줄 ${got.품셈} · 고정 줄 ${got.고정} · ` + + `하위 호표 참조 ${got.호표참조} · 빠짐 ${got.빠짐} · 엑셀 합계 = 우리 합계 ${got.같음}/${got.호표}` + + (got.그대로 ? ` · 이미 만든 호표 ${got.그대로} 그대로` : ""); + + /** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */ + async function importAll(): Promise { + const ask = + `호표 ${c.briefs().length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` + + "이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]"; + if (!(await showConfirmDialog(ask, "들여오기"))) return; + fixAll.disabled = true; + showLoadingOverlay(); + try { + const from = { 층: scope.layer, 주인: scope.owner, 키: key }; + const got = await buildHopyoAll(target, from); + if (got.호표 > got.그대로) document.dispatchEvent(new Event(HOPYO_BUILT)); + const next = + "다음 — 호표 [수정]에서 [품셈으로 바꾸기]" + + (got.이음후보 + ? ` · 이음 후보 ${got.이음후보} 줄(이름은 같고 단가가 다름)은 엑셀 단가 그대로 — 아래 [다음 — 이 공사 단가로 다시 잇기]에서 바꿈` + : ""); + const rows = [ + ...got.다름.map((x) => + el("tr", { + children: [ + el("td", { text: `제${x.번}호표 ${x.이름}` }), + el("td", { + text: `엑셀 ${won(x.엑셀)} · 우리 ${won(x.우리)}`, + className: "m02-hopyo__num", + }), + el("td", { text: won(x.차이), className: "m02-hopyo__num" }), + ], + }), + ), + ...got.실패.map((x) => { + const left = leftButton({ detail: x }, { scope, target, key, 파일: c.파일, 번: x.번 }); // prettier-ignore + return el("tr", { children: [el("td", { text: `제${x.번}호표` }), el("td", { text: x.까닭 }), ...(left ? [el("td", { children: [left] })] : [])] }); // prettier-ignore + }), + ]; + const relink = createButton({ + label: "다음 — 이 공사 단가로 다시 잇기", + size: "sm", + onClick: () => void openRelink(relinkKeys((got.만듦 ?? []).map((x) => x.키))), + }); // 705-2 + result.replaceChildren( + note(allText(got)), + note(next), + relink, + ...(rows.length ? [table(["호표", "합계 다름 · 실패", "차이"], rows)] : []), + ); + showToast(allText(got), got.빠짐 || got.실패.length ? "warning" : "success"); + } catch (error) { + showToast(`실패 — ${why(error)}`, "error"); + } finally { + hideLoadingOverlay(); + fixAll.disabled = false; + } + } + fixAll.addEventListener("click", () => void importAll()); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts index 63c928cd1..5319efc76 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts @@ -2,7 +2,7 @@ * M02_MasterTemplete_Hopyo_SheetSk.ts * 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 요구 627) — 탭 따라: * · 일위대가표 = 호표 목록(`Hopyo_Sheet` 판 그대로) · 단가산출표 · 단가산출 상세 = 단가산출 목록 - * (마법사 단가산출 칸 `sangeunStep` 좁은 꼴 한 벌 · 같은 캐시 · 같은 [확인] → [확인한 것 옮기기] · 마법사 체크와 상관없음) + * (`sangeunStep` 한 벌 · 같은 캐시 · [확인] → [확인한 것 옮기기] · [줄 n] = 시트 아래 줄 판 `lines` — 785-4) * · 목록 블록 줄 누름 → 지금 탭의 그 블록 자리(단가산출표 = 목록 행 · 상세 = 블록 범위 · [◂ 뒤로] 쌓임) * · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`) * · 단가산출 칸은 처음 단가산출 탭에 올 때 지음(판정 셈이 무거움) · 만들 층 ≠ 파일 층 · 쓰기 권한 없음 = 보기만 @@ -22,6 +22,10 @@ export interface SheetSkOptions { nav: SheetNav; /** 호표 목록 판 — 단가산출 탭이면 숨김 · 그 뒤에 단가산출 판을 붙임 */ side: HTMLElement; + /** 시트 아래 줄 판 자리 */ + lines: HTMLElement; + /** 판정 · 옮김 뒤(부르는 쪽 끝내기 글) */ + onChange?: () => void; } const sameLayer = (a: HopyoScope, b: HopyoScope): boolean => @@ -35,6 +39,16 @@ export function createSheetSk(opt: SheetSkOptions) { let step: SkStep | null = null; let sheet = ""; + /** 단가산출 칸 — 처음 필요할 때 지음 */ + function ensure(): SkStep { + if (!step) { + const readOnly = !opt.canWrite || !sameLayer(opt.scope, opt.target); + step = sangeunStep(opt.scope, opt.key, opt.파일, opt.onChange, { lines: opt.lines, onRow, readOnly }); // prettier-ignore + root.append(step.root); + } + return step; + } + /** 블록 줄 누름 — 지금 탭에서 그 블록 자리 · 없으면 다른 단가산출 탭 */ function onRow(no: number): void { const list = opt.nav.role(sheet) === "단가산출표"; @@ -46,6 +60,14 @@ export function createSheetSk(opt: SheetSkOptions) { return { root, + /** 아직 안 옮긴 블록 수 · 판정 전 null */ + count: (): ReturnType => step?.count() ?? null, + /** 그 블록으로 뜀 — 단가산출 상세(없으면 목록) 그 자리 · 줄 판 열림(안 이은 줄 창 [단가산출 걸음에서 확인]) */ + show(번: number): void { + const spot = opt.nav.spotOf(번, "단가산출상세") ?? opt.nav.spotOf(번, "단가산출표"); + if (spot) opt.nav.jumpTo(spot.시트, spot.행); + ensure().show(번); + }, /** 시트 고름 · 탭 바꿈 — 판 바꾸기 · 강조 */ at(sel: { 시트: string; 칸: string }): void { sheet = sel.시트; @@ -55,12 +77,7 @@ export function createSheetSk(opt: SheetSkOptions) { root.hidden = !sk; opt.side.parentElement?.classList.toggle("is-sk", sk); if (!sk) return; - if (!step) { - const readOnly = !opt.canWrite || !sameLayer(opt.scope, opt.target); - step = sangeunStep(opt.scope, opt.key, opt.파일, undefined, { narrow: true, onRow, readOnly }); // prettier-ignore - root.append(step.root); - } - step.mark(opt.nav.blockAt(sel.시트, Number(/\d+/.exec(sel.칸)?.[0] ?? 0))); + ensure().mark(opt.nav.blockAt(sel.시트, Number(/\d+/.exec(sel.칸)?.[0] ?? 0))); }, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index fe4456135..bcb7e519e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -1,16 +1,35 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Side.ts - * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) → - * 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] · 화면 떠남 때 올린 파일 지움) · - * 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) · + * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) — + * · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 엑셀 고르기 창 → 올림 → 본 칸 불러오기 보기 + * (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나) + * · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임) + * · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기 + * · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번 * 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createSegment } from "@ui/ui_template_segment"; import { t as L } from "@ui/ui_template_locale"; -import { pageScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { + listHopyo, + pageScope, + uploadHopyo, + type HopyoFile, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; -import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard"; +import { + buildHopyoImport, + LAYERS, + layerText, + writableLayers, + type HopyoImport, +} from "./M02_MasterTemplete_Hopyo_Import"; +import type { ManageLayer } from "./M02_MasterTemplete_Layer"; export interface HopyoSectionOptions { /** 이 층 쓰기 권한 — 별칭 장부 고침(구조물 조합 층이 제자리에서 바뀌어 열 때 읽음 · 720-4) */ @@ -30,54 +49,164 @@ export interface HopyoSection { paint: () => void; } -const WIZARD = "불러오기"; +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { const title = L("M02_HopyoImport"); + let layers: ManageLayer[] = []; + let pick: ManageLayer = "personal"; + const input = el("input", { + attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, + }) as HTMLInputElement; + input.hidden = true; const start = createButton({ label: "불러오기", variant: "filled", - onClick: () => void showView(WIZARD), + onClick: () => void choose(), }); start.classList.add("m02-layers__btn"); start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)"; start.dataset.hopyoWizardOpen = ""; + const seg = el("div", { className: "m02-side__row" }); + seg.hidden = true; + const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } }); 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 showView(LEDGER_TITLE)); + ledger.addEventListener("click", () => void showLedger()); const body = el("div", { className: "m02-hopyo-side", - children: [el("div", { className: "m02-side__row", children: [start] }), ledger], + children: [ + el("div", { className: "m02-side__row", children: [start, input] }), + seg, + jobs, + ledger, + ], }); - let open: { key: string; view: HTMLElement; leave?: () => void } | null = null; + let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null; - const paintList = (): void => { - ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE); - }; + /** 층 고름 · 작업 중 줄 — 쓸 수 있는 층을 처음 한 번 읽음 */ + async function refresh(): Promise { + if (!layers.length) { + layers = await writableLayers(); + if (!layers.includes(pick)) pick = layers[0] ?? "personal"; + if (layers.length > 1) { + const s = createSegment({ + options: LAYERS.filter((x) => layers.includes(x.layer)).map((x) => ({ value: x.layer, label: x.text })), // prettier-ignore + value: pick, + size: 24, + onChange: (v) => (pick = v as ManageLayer), + }); + s.root.dataset.hopyoLayer = ""; + seg.replaceChildren(s.root); + seg.hidden = false; + } + } + const rows = ( + await Promise.all( + layers.map((layer) => + listHopyo(scopeOf(layer)) + .then((list) => list.map((f) => ({ layer, f }))) + .catch(() => []), + ), + ) + ).flat(); + jobs.replaceChildren( + ...(rows.length + ? rows.map(({ layer, f }) => { + const row = createListRow({ + label: f.파일, + sub: `호표 ${f.호표수} · ${layerText(layer)} — 이어서`, + size: 28, + active: open?.imp !== undefined && open.key === f.키, + onClick: () => void show(layer, f), + }); + row.dataset.hopyoKey = f.키; + return row; + }) + : [createEmpty({ text: "작업 중인 불러오기 없음", small: true })]), + ); + } - async function showView(key: string): Promise { - if (open?.key === key && open.view.isConnected) return; - if (!(await opt.confirmLeave())) return; - open?.leave?.(); // 마법사 떠남 = 올린 파일 지움(743-1) - const wiz = key === WIZARD ? buildHopyoWizard() : null; - const view = wiz?.root ?? buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() }); - opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view); + /** [불러오기] — 지금 보기를 떠날 수 있는지 묻고 바로 파일 고르기 창 */ + async function choose(): Promise { + if (!layers.length) await refresh().catch(() => {}); + if (!layers.length) return void showToast("쓸 수 있는 층 없음", "warning"); + if (!(await leaveOk())) return; + input.click(); + } + input.addEventListener("change", () => { + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + start.disabled = true; + uploadHopyo(scopeOf(pick), file) + .then((got: HopyoFile & { 이어서?: boolean }) => { + if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); + else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); + return show(pick, got, true); + }) + .catch((error) => showToast(why(error), "error")) + .finally(() => (start.disabled = false)); + }); + + /** 지금 보기를 떠나도 되는지 — 저장 안 한 고침 · 고르고 안 만든 확인 캐시 */ + async function leaveOk(): Promise { + if (!(await opt.confirmLeave())) return false; + if (!open?.imp?.dirty()) return true; + return showConfirmDialog( + "지금 불러오기를 떠남 — 올린 파일을 지움 · 고르고 안 만든 호표는 빠짐(만든 것은 남음)", + "떠나기", + ); + } + + /** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */ + async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise { + if (open?.imp && open.key === file.키 && open.view.isConnected) return; + if (!checked && !(await leaveOk())) return; + open?.imp?.leave(); // 불러오기 떠남 = 올린 파일 지움(743-1) + const imp = buildHopyoImport({ + layer, + file, + onEnded: () => { + open = null; + ledger.classList.remove("is-active"); + opt.show(title, el("div", { className: "m02-hopyo" })); // 끝냄 — 빈 본 칸 + void refresh(); + }, + }); + opt.show(`${title} — ${file.파일}`, imp.root); opt.onOpen(); - open = { key, view, ...(wiz ? { leave: wiz.leave } : {}) }; - paintList(); + open = { key: file.키, view: imp.root, imp }; + ledger.classList.remove("is-active"); + void refresh(); + } + + async function showLedger(): Promise { + if (open?.key === LEDGER_TITLE && open.view.isConnected) return; + if (!(await leaveOk())) return; + open?.imp?.leave(); + const view = buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() }); + opt.show(`${title} — ${LEDGER_TITLE}`, view); + opt.onOpen(); + open = { key: LEDGER_TITLE, view }; + ledger.classList.add("is-active"); + void refresh(); } const paint = (): void => { if (open && !open.view.isConnected) { - open.leave?.(); // 다른 화면으로 = 마법사 떠남 + open.imp?.leave(); // 다른 화면으로 = 불러오기 떠남 open = null; - paintList(); + ledger.classList.remove("is-active"); + void refresh(); } }; + void refresh(); return { title, body, paint }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts deleted file mode 100644 index 6857f0c26..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ /dev/null @@ -1,315 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_Wizard.ts - * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: - * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림 - * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 새로 올림) · 작업 중 파일 이어서 - * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → - * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · - * 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 블록 줄 고름 · [확인] · 확인한 것만 SK · 778-5 D-5) · - * 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3) - * ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · - * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 - * 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 · - * 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림). - * ========================================================================== */ - -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 { createBanner } from "@ui/ui_template_banner"; -import { t as L } from "@ui/ui_template_locale"; -import { readHopyo } from "./M02_MasterTemplete_Hopyo_Api"; -import { hasDraft, draftKey } from "./M02_MasterTemplete_Hopyo_Check"; -import { - countLeft, - leftCount, - openLeft, - type LeftMap, - type LeftScope, -} from "./M02_MasterTemplete_Hopyo_Left"; -import { sangeunStep, type SkStep } from "./M02_MasterTemplete_Hopyo_WizardSk"; -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: "③ 끝내기" }, -]; - -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 { - const user = await fetchSessionUser().catch(() => null); - return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite); -} - -export interface HopyoWizard { - root: HTMLElement; - /** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */ - leave: () => void; -} - -export function buildHopyoWizard(): HopyoWizard { - 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"; - let wantSangeun = false; // 결과 「단가산출」 체크 - const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); - const held = new Map(); // 이 화면이 쥔 파일 「층|키」 → 층 - const hold = (layer: ManageLayer, key: string): void => - void held.set(`${layer}|${key}`, scopeOf(layer)); - const drop = (layer: ManageLayer, key: string): void => void held.delete(`${layer}|${key}`); - const leave = (): void => { - window.removeEventListener("pagehide", leave); - for (const [id, scope] of held) void deleteHopyo(scope, id.split("|")[1], true).catch(() => {}); - held.clear(); - }; - window.addEventListener("pagehide", leave); - - async function first(): Promise { - 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(L("M02_WizSkCheck"), { - checked: wantSangeun, - onChange: (on) => (wantSangeun = on), - }); - sangeun.root.dataset.hopyoSangeun = ""; - 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] }), - 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"); - hold(layer, file.키); - 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: () => { - drop(layer, file.키); - void first(); - }, - }), - ) - .catch((error: unknown) => showToast(why(error), "error")), - }); - const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); - finish.dataset.hopyoFinish = ""; - const at: LeftScope = { scope, target: scope, key: file.키 }; - const band = el("div", { attrs: { "data-hopyo-left": "" } }); - let nos: number[] = []; - let map: LeftMap | null = null; - let sk: SkStep | null = null; // 단가산출 칸 — 막힘 글 「확인 안 한 블록 n · 고를 줄 m」 - const onSk = (번: number): void => sk?.show(번); - const pick = createButton({ - label: L("M02_LeftOpen"), - variant: "ghost", - size: 28, - onClick: () => - openLeft({ ...at, 파일: file.파일, nos, onSk, ...(map ? { map } : {}) }, paint), - }); - pick.dataset.hopyoLeftOpen = ""; - /** 띠 — 남은 줄 있으면 [끝내기] 막음 · 까닭 · 남은 수 */ - function paint(got: LeftMap | null): void { - map = got; - const n = got ? leftCount(got) : null; - const w = sk?.count(); - const skLeft = w?.블록 ? `${L("M02_SkLeft").replace("{n}", String(w.블록)).replace("{m}", String(w.줄))} · ` : ""; // prettier-ignore - finish.disabled = !!n?.줄; - finish.title = n?.줄 ? skLeft + L("M02_LeftBlocked") : ""; - band.dataset.left = n ? String(n.줄) : ""; - band.replaceChildren( - createBanner({ - tone: !n ? "info" : n.줄 ? "warning" : "success", - text: !n - ? L("M02_LeftUnknown") - : n.줄 - ? `${skLeft}${L("M02_LeftBand").replace("{n}", String(n.줄)).replace("{h}", String(n.호표))} · ${L("M02_LeftBlocked")}` - : L("M02_LeftZero"), - actions: [pick], - }), - ); - } - paint(null); - async function recount(): Promise { - if (!nos.length) nos = (await readHopyo(scope, file.키)).호표.map((h) => h.번); - const got = await countLeft(at, nos, (i, all) => - band.replaceChildren(createText(`${L("M02_LeftCounting")} ${i + 1} / ${all}`, "muted", 14)), - ); - paint(got); - return got; - } - const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); - back.title = "작업은 그대로 보관 — 처음 화면 「작업 중」에서 이어서"; - if (wantSangeun) sk = sangeunStep(scope, file.키, file.파일, () => paint(map)); - const wrong = file.어긋남 ? ` · 검산 어긋남 ${file.어긋남}` : ""; - body.replaceChildren( - createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), - ...(wantSangeun - ? [createPanelHead({ title: L("M02_WizSkHead"), level: 4, divider: true }).root, sk!.root] - : []), - createPanelHead({ title: L("M02_WizIlwiHead"), level: 4, divider: true }).root, - createText( - "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감", - "hint", - ), - band, - el("div", { className: "ui-toolbar", children: [view, finish, back] }), - ); - - async function end(): Promise { - steps.set("3"); - finish.disabled = true; - let got: LeftMap; - try { - got = await recount(); - } catch (error) { - showToast(why(error), "error"); - finish.disabled = false; - return steps.set("2"); - } - if (leftCount(got).줄) { - showToast(L("M02_LeftBlocked"), "warning"); - openLeft({ ...at, 파일: file.파일, nos, onSk, map: got }, paint); - return steps.set("2"); - } - const loose = [...got.values()].filter( - (x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)), - ).length; - const w = sk?.count(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만 - const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` + - (loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : "") + - (w?.블록 - ? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}` - : ""), // 780-2 막지 않음 - "끝내기", - ); - if (!ok) return steps.set("2"); - try { - const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; - drop(layer, file.키); - showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); - } catch (error) { - showToast(why(error), "error"); - return steps.set("2"); - } - await first(); - } - } - - void first(); - return { root, leave }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts index 41cdc5eeb..23285dd56 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_WizardSk.ts @@ -1,16 +1,16 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_WizardSk.ts - * 일위대가 불러오기 마법사 ② 단가산출 칸(PLAN 778-5 D-5 · 요구 621 · 622 · 623 · 624) — 모두 줄로만 · 자동 없음. - * · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 이름 · 판정(「옮길 수 있음」 · - * 「고를 줄 n」 — 캐시 고름 겹침) · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표(가까운 기초 이름 · 참고) · 만든 SK · - * [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄] 펼침 = 줄 표(`Hopyo_SkLines`) · [보기] = 엑셀 보기 그 블록 첫 행 + * 단가산출 목록 판(PLAN 778-5 D-5 · 780-2 · 785-4 — 엑셀 보기 오른쪽 · 요구 621 · 622 · 623 · 624 · 627) — 모두 줄로만 · 자동 없음. + * · 열면 서버 줄 판정(`POST …/sangeun` 빈 확인 = 안 만듦 · D-3) → 블록 표: 단산N · 부름 칸 · 판정(「옮길 수 있음」 · + * 「고를 줄 n」 — 캐시 고름 겹침) · 만든 SK · [확인] 칩(고를 줄 0 일 때만 켜짐) · [줄 n] = 시트 아래 줄 판(`lines` 자리) 에 + * 줄 표(`Hopyo_SkLines` · 넓은 꼴) · 금액 · 근거는 줄 title + * · 맞춤 셈 중(409)이면 3 초마다 다시 판정(끝나면 저절로 표) * · 고름 · 확인 = 캐시 `m02-hopyo-sk:{층}:{주인}:{키}`(`SkPick` 꼴) — 누를 때 서버에 안 감 · 자동 저장 없음 * · [확인한 것 옮기기 (n)] = 몸 `{확인: [{번, 고름}]}` → 서버가 M01 지금 값으로 다시 셈 · 단위 SK(확인 안 한 블록 안 만듦) · * 만든 블록은 확인에서 뺌 · 못만듦 = 띠(까닭 그대로) · 「모두 확인」 단추 없음 - * · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄(마법사 [끝내기] 글) - * · 780-2(요구 627) — 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · - * 좁은 꼴(`narrow` = 엑셀 보기 오른쪽 · 칸 여섯 · 블록 줄 누름 = `onRow` · `mark(번)` 강조) · `readOnly` = 옮기기 숨김 · - * 두 벌 맞춤 = 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림 · 옮김 → 받은 판정 그대로) · 옮김 뒤 `HOPYO_BUILT` + * · `count()` = 아직 안 옮긴 블록 · 그중 일위대가 밖 · 고를 줄([끝내기] 글) + * · 부름 칸(「일위대가 n 곳」 · 「일위대가 밖」) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 블록 줄 누름 = `onRow` · + * `mark(번)` 강조 · `readOnly` = 옮기기 숨김 · 알림 `SK_SYNC`(같은 캐시 열쇠 · 고름 바뀜 → 다시 그림) · 옮김 뒤 `HOPYO_BUILT` * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -21,7 +21,9 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; import { createSegment } from "@ui/ui_template_segment"; -import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; +import { createPanelHead } from "@ui/ui_template_panel_head"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; +import { HOPYO_BUILT, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check"; import { formatPrice } from "../common_util/common_util_money"; import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import { judgeSangeun } from "./M02_MasterTemplete_Hopyo_SkApi"; @@ -63,15 +65,15 @@ export interface SkStep { } export interface SkStepOptions { - /** 좁은 꼴(엑셀 보기 오른쪽) — 칸 여섯 · 금액은 줄 title */ - narrow?: boolean; + /** 줄 판 자리(시트 아래) — [줄 n] 이 여기에 그 블록 줄 표를 그림 · 없으면 [줄 n] 없음 */ + lines?: HTMLElement; /** 블록 줄 누름(단추 밖) */ onRow?: (번: number) => void; /** 옮기기 숨김(만들 층 ≠ 파일 층 · 쓰기 권한 없음) */ readOnly?: boolean; } -/** 두 벌(마법사 칸 · 엑셀 보기 오른쪽) 맞춤 알림 — detail `{dk, from, got?}` */ +/** 맞춤 알림 — detail `{dk, from, got?}` */ export const SK_SYNC = "m02-hopyo-sk"; type SyncDetail = { dk: string; from: object; got?: SkJudged }; @@ -93,7 +95,7 @@ export function sangeunStep( let fails: SkJudged["못만듦"] = []; let current: number | null = null; // 강조 블록 let filter: SkFilter = "all"; - const open = new Set(); // 펼친 블록 + let shown: number | null = null; // 줄 판에 보인 블록 const out = el("div", { className: "m02-hopyo", attrs: { "data-wiz-sk": "" } }); const seg = createSegment({ options: [ @@ -119,7 +121,6 @@ export function sangeunStep( const root = el("div", { className: "m02-hopyo", children: [ - ...(o.narrow ? [] : [createText(L("M02_SkHint"), "hint", 14)]), seg.root, out, el("div", { className: "ui-toolbar", children: o.readOnly ? [again] : [move, again] }), @@ -154,6 +155,8 @@ export function sangeunStep( draw(); } catch (error) { out.replaceChildren(createBanner({ tone: "warning", text: why(error) })); + if (error instanceof StaleError) + window.setTimeout(() => root.isConnected && void judge(), POLL_MS); // 맞춤 셈 중 — 끝나면 저절로 } finally { again.disabled = false; onChange?.(); @@ -189,28 +192,14 @@ export function sangeunStep( } } - const cols: Column[] = o.narrow - ? [ - { label: L("M02_SkColBlock") }, - { label: L("M02_SkColCall"), width: "92px" }, - { label: L("M02_SkColVerdict"), width: "92px" }, - { label: L("M02_SkColMade"), width: "92px" }, - { label: L("M02_SkSure"), width: "64px" }, - { label: "", width: "60px" }, - ] - : [ - { label: L("M02_SkColBlock"), width: "72px" }, - { label: L("M02_SangeunColName") }, - { label: L("M02_SangeunColUnit"), width: "56px" }, - { label: L("M02_SkColCall"), width: "104px" }, - { label: L("M02_SkColVerdict") }, - { label: L("M02_SkColOurs"), align: "end" }, - { label: L("M02_SkColExcel"), align: "end" }, - { label: L("M02_SkColBasis") }, - { label: L("M02_SkColMade"), width: "110px" }, - { label: L("M02_SkSure"), width: "72px" }, - { label: "", width: "96px" }, - ]; + const cols: Column[] = [ + { label: L("M02_SkColBlock") }, + { label: L("M02_SkColCall"), width: "80px" }, + { label: L("M02_SkColVerdict"), width: "84px" }, + { label: L("M02_SkColMade"), width: "72px" }, + { label: L("M02_SkSure"), width: "56px" }, + { label: "", width: "56px" }, + ]; /** 부름 칸 — 「일위대가 n 곳」(title 호표 번) · 「일위대가 밖」 · 옛 서버(칸 없음) = — */ function callCell(b: SkBlock): HTMLElement | string { @@ -253,58 +242,48 @@ export function sangeunStep( const lines = createButton({ label: `${L("M02_SkOpen")} ${b.줄.length}`, size: 24, - variant: open.has(b.번) ? "filled" : "ghost", - onClick: () => { - if (open.has(b.번)) open.delete(b.번); - else open.add(b.번); - draw(); - }, + variant: shown === b.번 ? "filled" : "ghost", + onClick: () => showLines(shown === b.번 ? null : b.번), }); lines.dataset.skOpen = String(b.번); - const first = b.줄.find((l) => l.행 !== null)?.행 ?? null; - const view = first !== null && !o.narrow ? createButton({ label: L("M02_SkSheet"), size: 24, variant: "link", onClick: () => sheet(first) }) : ""; // prettier-ignore + lines.hidden = !o.lines; const name = `단산${b.번} ${b.이름 ?? ""}${b.단위 ? ` [${b.단위}]` : ""}`; - const head = tableRow( - cols, - o.narrow - ? [name, callCell(b), verdict, made, sure.root, lines] - : [ - `단산${b.번}`, - b.이름 ?? "", - b.단위 ?? "", - callCell(b), - verdict, - won(b.우리금액), - won(b.엑셀금액), - basis, - made, - sure.root, - el("div", { className: "ui-toolbar", children: [lines, view] }), - ], - ); + const head = tableRow(cols, [name, callCell(b), verdict, made, sure.root, lines]); head.dataset.skBlock = String(b.번); if (b.번 === current) head.classList.add("is-current"); - if (o.narrow) { - head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore - head.addEventListener("click", (e) => { - if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번); - }); - } - if (!open.has(b.번)) return [head]; - const table = skLineTable({ - scope, - block: b, - picks, - onPick: (at, p) => { - save(tidy(setPick(load(), b.번, at, p), got!.판정)); - draw(); - tell(); - }, - onSheet: sheet, + head.title = `${name} · ${L("M02_SkColOurs")} ${won(b.우리금액)} · ${L("M02_SkColExcel")} ${won(b.엑셀금액)}`; // prettier-ignore + head.addEventListener("click", (e) => { + if (!(e.target as Element).closest("button, label, input")) o.onRow?.(b.번); }); - const sub = tableRow(cols, [{ content: table, colSpan: cols.length }]); - sub.className = "is-sub"; - return [head, sub]; + return [head]; + } + + /** 줄 판 — 그 블록 줄 표(넓은 꼴) · 고름은 같은 캐시 · null = 닫음 */ + function showLines(번: number | null): void { + shown = 번; + draw(); // 줄 판 · 목록 단추 칠을 한 번에 + } + function drawLines(): void { + const host = o.lines; + if (!host) return; + const b = shown === null ? null : got?.판정.find((x) => x.번 === shown); + host.hidden = !b; + if (!b) return host.replaceChildren(); + const close = createButton({ label: L("M02_Close"), size: 24, variant: "ghost", onClick: () => showLines(null) }); // prettier-ignore + host.replaceChildren( + createPanelHead({ title: `단산${b.번} ${b.이름 ?? ""}`, level: 4, divider: true, actions: [close] }).root, // prettier-ignore + skLineTable({ + scope, + block: b, + picks: picksOf(load(), b.번), + onPick: (at, p) => { + save(tidy(setPick(load(), b.번, at, p), got!.판정)); + draw(); + tell(); + }, + onSheet: sheet, + }), + ); } function draw(): void { @@ -344,6 +323,7 @@ export function sangeunStep( "{n}", String(d.확인.length), ); + drawLines(); onChange?.(); } @@ -352,8 +332,7 @@ export function sangeunStep( root, count: () => (got ? waiting(load(), got.판정) : null), show: (번) => { - open.add(번); - draw(); + showLines(번); out.querySelector(`[data-sk-block="${번}"]`)?.scrollIntoView({ block: "center" }); }, mark: (번) => { diff --git a/resources/tester/test_627_sheet_sk.py b/resources/tester/test_627_sheet_sk.py index 610cfdefe..b7a33359b 100644 --- a/resources/tester/test_627_sheet_sk.py +++ b/resources/tester/test_627_sheet_sk.py @@ -44,7 +44,10 @@ def test_판_바꿈_목록_지음_강조_뛰기(): ] assert [on for _, on in got["toggled"]] == [False, True, False, True, True] (step,) = got["made"] - assert step["args"]["o"] == {"narrow": True, "onRow": True, "readOnly": False} + assert step["args"]["o"] == { + "onRow": True, + "readOnly": False, + } # `lines` 자리는 이 대역에서 없음 assert step["args"]["key"] == "HP-000001" assert step["marks"] == [8, 8, 0] # 목록 행 12 → 단산8 · 상세 85 행 → 단산8 · 2 행 → 블록 0 # 목록 줄 누름 = 지금 탭의 자리(단가산출표 = 목록 행 · 상세 = 블록 범위) @@ -81,18 +84,18 @@ def test_캐시_공유_알림_칠_다시(): assert "sangeunStep(opt.scope, opt.key" in side # 마법사와 같은 파일 층 · 키 nav = _src("SheetNav") assert "blockAt(sheet: string, row: number)" in nav and "spotOf(no: number, role: Role)" in nav - wizard = _src("Wizard") + wizard = _src("Import") assert 'L("M02_SkUnmoved")' in wizard and "finish.disabled = !!n?.줄;" in wizard # 밖 = 안 막음 tone = (M02 / "logic" / "M02_MasterTemplete_Hopyo_SheetMap.ts").read_text(encoding="utf-8") assert '글: "일위대가 밖"' in tone def test_부품_상자만_700줄(): - for name in ("SheetSk", "Sheet", "SheetNav", "WizardSk", "Wizard"): + for name in ("SheetSk", "Sheet", "SheetMake", "SheetNav", "WizardSk", "Import", "Side"): src = _src(name) assert len(src.splitlines()) <= 700, name assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", src), name - for name in ("SheetSk", "WizardSk"): + for name in ("SheetSk", "WizardSk", "Import"): assert not re.search(r'el\("(button|input|select)"| str: return (M02 / name).read_text(encoding="utf-8") -def test_마법사_끝내기_막음_단가산출_칸_목록_열(): - wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts") +def test_불러오기_끝내기_막음_단가산출_칸_목록_열(): + wizard = _text("M02_MasterTemplete_Hopyo_Import.ts") end = wizard.split("async function end()")[1] assert end.index("await recount()") < end.index("showConfirmDialog(") # 끝내기 전 다시 셈 assert "if (leftCount(got).줄)" in end and "openLeft(" in end # 남으면 막음 · 표 - assert "finish.disabled = !!n?.줄;" in wizard # 띠 · 남은 수 · [끝내기] 막음 - assert "sangeunStep(scope, file.키, file.파일" in wizard and "/sangeun`" not in wizard + assert "finish.disabled = !!n?.줄;" in wizard # 배지 · 남은 수 · [끝내기] 막음 + assert ( + "body?.skCount()" in wizard and "/sangeun`" not in wizard + ) # 단가산출 칸 = 엑셀 보기 오른쪽 판 + # 785-4 흠 — [안 이은 줄 고르기] · [끝내기] 모두 엑셀 보기가 읽은 호표 번으로 셈(빈 번 「0」 오표시 없음) + pick = wizard.split("async function openPick()")[1].split("async function ensureNos")[0] + assert "await ensureNos();" in pick and "openLeft(" in pick + assert "nos = got.호표.map((h) => h.번)" in wizard + assert "ensureNos" in wizard.split("async function recount()")[1] sk = _text( "M02_MasterTemplete_Hopyo_WizardSk.ts" ) # 778-5 D-5 — 줄 고름 · 확인한 것만(옛 기초 고름 걷음) @@ -121,6 +128,7 @@ def test_마법사_끝내기_막음_단가산출_칸_목록_열(): for name in ( "M02_MasterTemplete_Hopyo_Left.ts", "M02_MasterTemplete_Hopyo_WizardSk.ts", + "M02_MasterTemplete_Hopyo_Import.ts", "M02_MasterTemplete_Labor_Unlinked.ts", "M02_MasterTemplete_Hopyo_Lost.ts", ): diff --git a/resources/tester/test_778_5_sk_pick_screen.py b/resources/tester/test_778_5_sk_pick_screen.py index bd5ffd5cd..0b5e06ce3 100644 --- a/resources/tester/test_778_5_sk_pick_screen.py +++ b/resources/tester/test_778_5_sk_pick_screen.py @@ -121,8 +121,8 @@ def test_화면_자동_없음_부품_상자만(): assert "단산행: 행" in wiz # 엑셀 보기 뛰기 lines = _src("SkLines") assert "lineFind(" in lines and "unitTone(" in lines # 찾기 창 · 엑셀 칠과 한 규칙 - wizard = _src("Wizard") - assert 'L("M02_SkLeft")' in wizard and "sk?.count()" in wizard # [끝내기] 막힘 글 + wizard = _src("Import") + assert 'L("M02_SkLeft")' in wizard and "body?.skCount()" in wizard # [끝내기] 막힘 글 for name in ("WizardSk", "SkLines", "SkApi", "Lost", "Compare"): src = _src(name) assert len(src.splitlines()) <= 700, name diff --git a/resources/tester/test_782_1_small_flaws.py b/resources/tester/test_782_1_small_flaws.py index 0f3478ce9..8a929e2cb 100644 --- a/resources/tester/test_782_1_small_flaws.py +++ b/resources/tester/test_782_1_small_flaws.py @@ -6,7 +6,7 @@ ROOT = Path(__file__).resolve().parents[2] def test_finish_confirm_args_in_order(): - src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts").read_text("utf-8") + src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts").read_text("utf-8") at = src.index("showConfirmDialog(") block = src[at : src.index(");", at)] assert block.rstrip().endswith('"끝내기",') or '"끝내기"' in block.splitlines()[-1] diff --git a/resources/tester/test_785_4_import_screen.py b/resources/tester/test_785_4_import_screen.py new file mode 100644 index 000000000..2fb3269e6 --- /dev/null +++ b/resources/tester/test_785_4_import_screen.py @@ -0,0 +1,91 @@ +"""785-4 불러오기 화면 껍데기 — 가운데 상세 페이지(마법사) 없음 · 왼쪽 [불러오기] → 바로 본 칸 엑셀 + 오른쪽 목록 · +머리 막대 [끝내기] · 흠 셋(안 이은 줄 0 오표시 · 「맞춤 셈 중」 멈춤 · 좁은 판 단가산출 줄 짓눌림) · 부품 상자만 · 700 줄. +소스 글만 읽음(서버 · 화면 없이 돎).""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _src(name: str) -> str: + return (M02 / f"M02_MasterTemplete_Hopyo_{name}.ts").read_text(encoding="utf-8") + + +def test_가운데_상세_페이지_없음(): + assert not (M02 / "M02_MasterTemplete_Hopyo_Wizard.ts").exists() + every = "\n".join(p.read_text(encoding="utf-8") for p in M02.glob("*.ts")) + for gone in ("buildHopyoWizard", "HopyoWizard", "wantSangeun", "M02_WizSkCheck", "STEPS"): + assert gone not in every, gone + for key in ("M02_WizSkHead", "M02_WizIlwiHead", "M02_SkHint"): + assert key not in (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + + +def test_누름_하나_바로_엑셀(): + side = _src("Side") + choose = side.split("async function choose()")[1].split("input.addEventListener")[0] + assert "input.click();" in choose # [불러오기] = 바로 파일 고르기 창 + change = side.split('input.addEventListener("change"')[1].split("async function leaveOk")[0] + assert ( + "uploadHopyo(scopeOf(pick), file)" in change and "return show(pick, got, true);" in change + ) + show = side.split("async function show(")[1].split("async function showLedger")[0] + assert "buildHopyoImport({" in show and "opt.show(" in show # 올림 뒤 본 칸 바로 + imp = _src("Import") + assert "m.buildHopyoSheet({" in imp and "headExtra: [layerNote, badge, pickBtn, finish]" in imp + + +def test_결과_층_고름_둘_이상일_때만(): + side = _src("Side") + assert "layers.length > 1" in side and "seg.hidden = false;" in side + assert "seg.hidden = true;" in side # 처음 숨김 — 일반(개인 하나)은 안 보임 + assert 'let pick: ManageLayer = "personal";' in side + + +def test_흠_셋_고침(): + imp = _src("Import") + # ① 안 이은 줄 0 오표시 — 빈 호표 번으로 안 부름 · 엑셀 보기가 읽은 번을 씀 + pick = imp.split("async function openPick()")[1].split("async function ensureNos")[0] + assert "await ensureNos();" in pick + assert "nos = got.호표.map((h) => h.번)" in imp + assert "ensureNos" in imp.split("async function recount()")[1] + # ② 「맞춤 셈 중」(409) = 3 초마다 저절로 다시 판정 + sk = _src("WizardSk") + assert "error instanceof StaleError" in sk and "void judge()" in sk and "POLL_MS" in sk + # ③ 좁은 판 줄 짓눌림 — [줄 n] = 시트 아래 줄 판(넓은 줄 표) · 판 안 펼침 없음 + assert "skLineTable({" in sk.split("function drawLines()")[1] + assert "is-sub" not in sk and "o.narrow" not in sk + sheet = _src("Sheet") + assert 'attrs: { "data-hopyo-lines": "" }' in sheet and "lines," in sheet + assert "lines: HTMLElement;" in _src("SheetSk") + + +def test_끝내기_머리_막대_끝냄_뒤_빈_본_칸(): + imp = _src("Import") + end = imp.split("async function end()")[1] + assert end.index("await recount()") < end.index("showConfirmDialog(") + assert "deleteHopyo(scope, file.키)" in end and "a.onEnded();" in end + side = _src("Side") + assert "onEnded: () => {" in side and "void refresh();" in side + sheet = _src("Sheet") + assert "dropButton" not in sheet and "onDeleted" not in sheet # [삭제] 걷음 + + +def test_부품_상자만_700줄_늘리지_않음(): + for name in ("Import", "Side", "Sheet", "SheetMake", "SheetSk", "WizardSk"): + src = _src(name) + assert len(src.splitlines()) <= 700, name + assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", src), name # 색 = 공통 값 + for name in ("Import", "SheetMake", "SheetSk", "WizardSk"): + assert not re.search(r'el\("(button|input|select)"| 1" in hopyo_side # 층 고름 = 둘 이상일 때만(일반 = 개인 하나) + for gone in ("wantSangeun", "STEPS", "second(", "steps.set", "data-hopyo-sangeun"): + assert gone not in imp + hopyo_side, gone # 중간 걸음 · 단가산출 체크 없음 def test_로직_추가_한_곳과_파일_규칙(): @@ -384,9 +392,11 @@ def test_로직_추가_한_곳과_파일_규칙(): def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' - side = _text("M02_MasterTemplete_Hopyo_Wizard.ts") - assert "second(pick, got);" in side # 올린 직후 ② 걸음 - assert lazy in side.split("function second(")[1].split("async function end(")[0] + side = _text("M02_MasterTemplete_Hopyo_Import.ts") + assert "return show(pick, got, true);" in _text( + "M02_MasterTemplete_Hopyo_Side.ts" + ) # 올린 직후 바로 본 칸 + assert lazy in side and "m.buildHopyoSheet({" in side # 본 칸 몸 = 늦게 불러옴 check = _text("M02_MasterTemplete_Hopyo_Check.ts") assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check assert "엑셀에서 보기" not in check @@ -451,20 +461,20 @@ def test_확인_창_손질_339(): assert "precombo.refresh()" in onb and "sangeun.refresh()" in onb # 717-2a — 둘 다 새로고침 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 "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in _text( + "M02_MasterTemplete_Hopyo_SheetMake.ts" + ) def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359(): - """339 덧고침 2 → 359-1 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」 · [삭제])은 - 엑셀 보기 창 머리 · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께).""" + """339 덧고침 2 → 359-1 · 785-4 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」)은 + 엑셀 보기 몸 머리(부르는 쪽 덧붙임 `headExtra` — 층 · 안 이은 줄 · [끝내기]) · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께).""" sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") - head = sheet.split("function paintHead(")[1].split("const dropButton")[0] + head = sheet.split("function paintHead(")[1].split("/** 목록 강조")[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") + assert "...(opt.headExtra ?? [])" in head and "opt.onRead?.(file);" in head + assert "dropButton" not in sheet and "onDeleted" not in sheet # [삭제] 걷음 = [끝내기] 한 길 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 @@ -524,7 +534,7 @@ def test_명사형_단추와_별칭_장부_풀이_359(): assert f"label: {label}" in ledger, label assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger") - mine += ("Hopyo_Wizard",) + mine += ("Hopyo_Import", "Hopyo_SheetMake") every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine) for gone in ( "고친 것 버림", @@ -540,15 +550,16 @@ def test_명사형_단추와_별칭_장부_풀이_359(): def test_372_2_저장은_개인_층에_삭제는_화면_층_권한(): - """718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음 · - 743-1 — 지운 파일은 떠남 지움 목록에서 뺌 · 떠남(화면 바뀜 · 페이지 닫음) = 쥔 파일 지움.""" - side = _text("M02_MasterTemplete_Hopyo_Wizard.ts") - open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0] + """718-1 — 만들 층 = 올린 층 · [끝내기] 로 올린 파일 지움 → 빈 본 칸(785-4: 엑셀 보기 [삭제] 걷음) · + 743-1 — 떠남(화면 바뀜 · 페이지 닫음) = 쥔 파일 지움.""" + imp = _text("M02_MasterTemplete_Hopyo_Import.ts") + open_ = imp.split("m.buildHopyoSheet({")[1].split("});")[0] assert "target: scope," in open_ and "canWrite: true," in open_ - assert "drop(layer, file.키);" in open_ and "void first();" in open_ - assert 'addEventListener("pagehide", leave)' in side and "return { root, leave };" in side + assert "onDeleted" not in imp and "dropButton" not in _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert "a.onEnded();" in imp and "held = false;" in imp + assert 'addEventListener("pagehide", leave)' in imp and " leave,\n" in imp panel = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert panel.count("open?.leave?.()") == 1 and panel.count("open.leave?.()") == 1 + assert panel.count("open?.imp?.leave()") == 2 and panel.count("open.imp?.leave()") == 1 def test_779_세_보기_칠_뛰기_창_다시_씀(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 4638c0214..05a6bfbc9 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -238,16 +238,8 @@ export const ui_locales_m2 = { M02_FindNewMaterial: ["신규 자재", "New material"], M02_FindElementLater: ["일위대가 표 개편 뒤", "After the unit-cost table rework"], M02_FindOptions: ["옵션 {n}", "{n} options"], - // 729-3 불러오기 마법사 — 단가산출 단계 · 안 이은 줄 · 일위대가표 띠 - M02_WizSkCheck: [ - "단가산출(블록 줄 → 확인한 것만 SK)", - "Unit-cost calcs (lines → confirmed only)", - ], + // 729-3 불러오기 — 안 이은 줄 · 일위대가표 띠 // 778-5 D-5 마법사 단가산출 줄 고름(제안 · 확인 뒤 옮김) - M02_SkHint: [ - "블록마다 줄을 보고 고를 줄을 고른 뒤 [확인] → [확인한 것 옮기기] · 자동으로 옮기지 않음 · 금액 = M01 지금 값(엑셀 금액은 참고)", - "Review each block, pick open lines, confirm, then move · nothing moves automatically · amounts = current M01 (Excel for reference)", - ], M02_SkSummary: [ "블록 {b} · 옮길 수 있음 {ok} · 고를 줄 있음 {wait} · 옮김(SK) {made} · 확인 {sure}", "Blocks {b} · ready {ok} · open lines {wait} · moved (SK) {made} · confirmed {sure}", @@ -306,8 +298,6 @@ export const ui_locales_m2 = { M02_SkDropWhy: ["까닭", "Reason"], M02_SkDropDefault: ["우리에 없음", "Not in ours"], M02_SkClear: ["고름 지움", "Clear"], - M02_WizSkHead: ["단가산출", "Unit-cost calcs"], - M02_WizIlwiHead: ["일위대가", "Unit-price sheets"], M02_LeftTitle: ["안 이은 줄", "Unlinked rows"], M02_LeftHint: [ "만들면 M01 · 기초 · SK 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성",