feat(785-4): 불러오기 화면 껍데기 — 가운데 상세 페이지(마법사) 없앰 · 왼쪽 [불러오기] → 바로 본 칸 엑셀 + 오른쪽 목록

- 새 Hopyo_Import.ts(머리 막대 층 · 안 이은 줄 배지 · [끝내기] · 떠남 지움) · Hopyo_Wizard.ts 지움
- Hopyo_Side.ts = 올림 · 층 작은 고름(둘 이상일 때만) · 작업 중 줄 · 다른 엑셀 물음
- Hopyo_Sheet.ts = 몸(buildHopyoSheet) + 모달 껍데기 · 만들기 셋 → 새 Hopyo_SheetMake.ts · [삭제] 걷음 · 찾기 · 체크 부품으로
- 흠 셋: 안 이은 줄 0 오표시(빈 호표 번) · 맞춤 셈 중 멈춤(3 초 저절로 다시) · 좁은 판 줄 짓눌림(시트 아래 줄 판 자리)
- 시험: 785-4 지킴 시험 새로 · 낡은 마법사 · 모달 [삭제] · 좁은 꼴 인자 대조를 새 파일 · 새 길로 고침(Wizard 삭제 · SheetMake 이동 · narrow 걷음)
This commit is contained in:
2026-11-04 23:08:39 +09:00
parent c7d2e99a25
commit 73f113ba34
15 changed files with 1024 additions and 775 deletions
@@ -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%);
}
}
@@ -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<ManageLayer[]> {
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<void> {
try {
await ensureNos();
} catch (error) {
return void showToast(why(error), "error");
}
openLeft({ ...at, 파일: file.파일, nos, onSk, ...(map ? { map } : {}) }, paint);
}
async function ensureNos(): Promise<void> {
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<LeftMap> {
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<void> {
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))),
};
}
@@ -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<string, (o: HopyoSheetOptions) => 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<Sheet, "칸" | "열">): 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<void> {
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<void> {
const nos = [...checked].sort((a, b) => a - b);
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
const ask =
`고른 호표 ${nos.length}개를 체크한 줄로 ${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<void> {
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<void> {
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(번),
};
}
@@ -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<number>;
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<void> {
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<void> {
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<void> {
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());
}
@@ -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<SkStep["count"]> => 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)));
},
};
}
@@ -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<void> {
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<void> {
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<void> {
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<boolean> {
if (!(await opt.confirmLeave())) return false;
if (!open?.imp?.dirty()) return true;
return showConfirmDialog(
"지금 불러오기를 떠남 — 올린 파일을 지움 · 고르고 안 만든 호표는 빠짐(만든 것은 남음)",
"떠나기",
);
}
/** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */
async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise<void> {
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<void> {
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 };
}
@@ -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<ManageLayer[]> {
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<string, HopyoScope>(); // 이 화면이 쥔 파일 「층|키」 → 층
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<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(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<LeftMap> {
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<void> {
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 };
}
@@ -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<number>(); // 펼친 블록
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: (번) => {
+7 -4
View File
@@ -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)"|<button|<input', _src(name)), name
css = (M02 / "M02_MasterTemplete_Hopyo.css").read_text(encoding="utf-8")
block = css.split(".m02-hopyo-sheet.is-sk", 1)[1].split("/*", 1)[0]
@@ -90,13 +90,20 @@ def _text(name: str) -> 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",
):
@@ -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
+1 -1
View File
@@ -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]
@@ -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)"|<button|<input', _src(name)), name
assert len(_src("Check").splitlines()) <= 662 and len(_src("Api").splitlines()) <= 632
css = (M02 / "M02_MasterTemplete_Hopyo.css").read_text(encoding="utf-8")
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", css) and ".m02-hopyo-import" in css
def test_해시_캐시_열쇠_그대로():
check = _src("Check")
assert "m02-hopyo-check:" in check # 확인 창 캐시 열쇠
assert "m02-hopyo-sk:" in _src("WizardSk") # 단가산출 캐시 열쇠
assert "location.hash" not in _src("Import") + _src("Side") # 새 해시 열쇠 없음
+43 -32
View File
@@ -29,7 +29,8 @@ NEW = (
"Hopyo_CheckRows",
"Hopyo_Choose",
"Hopyo_Ledger",
"Hopyo_Wizard",
"Hopyo_Import",
"Hopyo_SheetMake",
)
ROW = {
"층": "system",
@@ -306,13 +307,16 @@ def test_옛_즉시_확정과_바로_만들기_없음():
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
assert users == [
"Hopyo_Check",
"Hopyo_SheetMake",
] # 확인 창 · 엑셀 보기 [저장] 물음 뒤(785-4 만들기 셋 = SheetMake)
check = texts["Hopyo_Check"]
assert '`${L("M02_PrecomboTitle")} 생성`' in check and "맞음 · 별칭 일괄 체크" in check
assert "확인하고" not in check # 359-4 명사형
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
assert "m02-hopyo-check:" in check and "POLL_MS" in check
sheet = texts["Hopyo_Sheet"]
sheet = texts["Hopyo_SheetMake"]
# 359-2 — 펼침 표 · [확인 창 열기] · [이대로 n개 만들기] 없음 · [저장] = 물음 한 번 뒤 체크 줄로 만들기
for gone in ("확인 창 열기", "이대로", "review(", "확인하고 만들기"):
assert gone not in sheet, gone
@@ -322,6 +326,7 @@ def test_옛_즉시_확정과_바로_만들기_없음():
assert (
"buildOrder(" in sheet and "unmadeKids(" in sheet and "leftAt(" in sheet
) # 하위 먼저 · 함께(777-1)
sheet = texts["Hopyo_Sheet"] # 목록 · [수정] = 엑셀 보기 몸
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
row = sheet.split("function paintList(")[1]
@@ -347,22 +352,25 @@ def test_좌측_판_자리와_권한():
assert "canWrite: opt.canWrite," in side # 구조물 조합 층 쓰기 권한(720-4 함수)
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
# 718-1 · 785-4 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 바로 파일 고르기 → 본 칸 불러오기 보기
assert 'L("M02_HopyoImport")' in hopyo_side and "일위대가 실무자료" not in hopyo_side
start = hopyo_side.split("const start = createButton({")[1].split("start.title")[0]
assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start
assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side
assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side
assert "buildHopyoImport({" in hopyo_side and "Hopyo_Wizard" not in hopyo_side
assert not (M02 / "M02_MasterTemplete_Hopyo_Wizard.ts").exists() # 가운데 상세 페이지 없음
assert "uploadHopyo(" in hopyo_side and "openHopyoSheet" not in hopyo_side
assert (
"buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo_side
) # [별칭 장부]
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인
assert (
"let wantSangeun = false;" in wizard and "sangeunStep(scope, file.키, file.파일" in wizard
) # 결과 단가산출 체크 → 단가산출 칸(729-3)
imp = _text("M02_MasterTemplete_Hopyo_Import.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
choose = hopyo_side.split("async function choose()")[1].split("input.addEventListener")[0]
assert "input.click();" in choose # 누름 하나 — 바로 파일 고르기 창
assert "accessFor(layer, null, user).canWrite" in imp # 결과 층 = 쓸 수 있는 층만
assert 'let pick: ManageLayer = "personal";' in hopyo_side # 기본 개인
assert "layers.length > 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_세_보기_칠_뛰기_창_다시_씀():
+1 -11
View File
@@ -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 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성",