Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -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: (번) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") # 새 해시 열쇠 없음
|
||||
@@ -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_세_보기_칠_뛰기_창_다시_씀():
|
||||
|
||||
@@ -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 에 안 이어질 엑셀 줄 — 줄마다 이을 것을 고름 · 고른 뒤 엑셀 보기 창 [저장] 또는 호표 [수정] 생성",
|
||||
|
||||
Reference in New Issue
Block a user