feat(779-4 · 779-6): 엑셀 보기 창 세 보기 · 연결 칠 · 범례 · 뛰기 · 뒤로 · 열린 창 다시 씀
- 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 = 서버 역할 id(이름으로 안 찾음 · STmate 단가산출근거 뛰기 풀림) · 두 형식 같은 탭 글 · 원본 시트 이름은 막대 - 새 logic/M02_MasterTemplete_Hopyo_SheetMap.ts(순수 — 줄 칠 · 블록 칠 · 머리 행 = 가장 나쁜 칠 · 뛸 곳) · 「이어짐」 = 확인 창 체크 규칙(캐시가 이김) - 새 M02_MasterTemplete_Hopyo_SheetNav.ts — 범례 createLegend · 맥락 단추(상세 · 목록 · 하위 호표 · 부르는 줄 차례로) · 뒤로 · 두 번 누름 = 첫 단추 · 다시 칠(목록 다시 읽음 · 캐시 고침 · 만들기) · 칠은 A 열 ~ 끝 열 · 띠는 A 열 - Hopyo_Sheet.ts = 잇는 줄만(580 → 609) · 줄 행 누름 = 그 줄 그대로 · 머리 행 = 호표 영역 - 779-6 — 같은 파일 창은 새 모달 대신 그 창을 앞으로 꺼내 그 자리로 · 닫기 = 제자리로 내림 · 마지막 격자 다시 씀(확인 · 고르기 창 [엑셀 보기] 모두 openSheet 한 곳으로 지나감) - Hopyo_Check.ts saveDraft = 다시 칠 알림 한 줄(663 → 662) · Hopyo_Api.ts at 내보냄(676 그대로) - 서버 Match.view — 목록 「후보」 중 이름 후보 안 찾은 줄만 다시 셈(뒤 셈 끝 뒤) → 확인 창 「없음」과 같음(빨강) · 북부청 임도 목록 응답 2.81 → 3.49 초(다시 셈 48 줄 · 없음 11) - 시험: 새 test_779_4_sheet_map(꼴 표 · 블록 칠 · 실물 EST · STmate 칠 행 수 · 뛸 곳) · test_m02_hopyo_screen 779 지킴 더함 - 낡은 시험 고침: test_779_2 기대 「후보 ≠ 없음 허용」 → 「모두 같음」(까닭 = 이번 덧 셈) · 도우미 helper_m02_hopyo_check · helper_729_3_hopyo_left 에 document 대역(까닭 = saveDraft 알림이 document 를 씀) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -446,6 +446,15 @@ def units_of(doc: dict[str, Any]) -> dict[int, str]:
|
||||
return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []}
|
||||
|
||||
|
||||
def _state(line: dict[str, Any], ctx: Any, lines: Any) -> str:
|
||||
"""목록 줄 상태 — 「후보」 중 이름 후보를 안 찾은 줄(숫자 판정 없음 · 자재 = 꼬리표 있음)만
|
||||
다시 셈(779-4 · 확인 창 「없음」과 같게 · 숫자 판정 「후보」 · 뒤 셈 중은 그대로)."""
|
||||
got = line["맞춤"]
|
||||
if got["상태"] != "후보" or "꼬리표" not in got or not ctx.calc["끝"]:
|
||||
return got["상태"]
|
||||
return lines.match_line(line, ctx, True)["상태"]
|
||||
|
||||
|
||||
def view(folder: Path, key: str, no: int | None = None, to: Path | None = None) -> dict[str, Any]:
|
||||
"""실무자료 보기 — `no` 없으면 호표마다 [체크 제안 수, 줄 수] · `줄상태 [[차례, 상태, 체크]]` ·
|
||||
`만든`(그 호표 SC 키 · 없으면 None) · 있으면 그 호표 줄마다 맞춤(3-3)
|
||||
@@ -458,9 +467,9 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None)
|
||||
hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []]
|
||||
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된")
|
||||
brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos]
|
||||
for one, h in zip(brief, hopyos): # 779-2 · 이미 센 줄 상태를 버리지 않음(덧 셈 0)
|
||||
for one, h in zip(brief, hopyos): # 779-2 · 이미 센 줄 상태를 버리지 않음
|
||||
one["줄상태"] = [
|
||||
[i, x["맞춤"]["상태"], x["맞춤"]["체크"]] for i, x in enumerate(h["줄"])
|
||||
[i, _state(x, ctx, lines), x["맞춤"]["체크"]] for i, x in enumerate(h["줄"])
|
||||
]
|
||||
one["만든"] = ctx.done.get(lines.hopyo_build.mark(key, int(h["번"])))
|
||||
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
|
||||
|
||||
@@ -260,6 +260,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 세 보기 막대 · 범례(779-4) — 시트 위 한 줄씩 · 넘치면 감김 */
|
||||
.m02-hopyo-sheet__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -451,7 +451,7 @@ export interface LedgerRow extends LedgerEntry {
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 화면 층(구조물 조합 층) */
|
||||
export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() });
|
||||
const at = (scope: HopyoScope, path: string): string => {
|
||||
export const at = (scope: HopyoScope, path: string): string => {
|
||||
const who = scope.owner && scope.layer !== "system" ? `owner=${enc(scope.owner)}` : "";
|
||||
const join = path.includes("?") ? "&" : "?";
|
||||
return `/layers/${scope.layer}${path}${who ? join + who : ""}`;
|
||||
|
||||
@@ -94,9 +94,8 @@ export function hasDraft(k: string): boolean {
|
||||
export function saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
} catch {} // 캐시 못 씀 = 이 화면에만 남음
|
||||
document.dispatchEvent(new Event("m02-hopyo-draft")); // 엑셀 보기 창 다시 칠(779-4 SheetNav.HOPYO_DRAFT)
|
||||
}
|
||||
export function dropDraft(k: string): void {
|
||||
try {
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 ·
|
||||
* 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) ·
|
||||
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) ·
|
||||
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 ·
|
||||
* 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 창은 다시 씀 = 앞으로 꺼내 그 자리로 · 779-6) ·
|
||||
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름
|
||||
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
|
||||
* · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로
|
||||
* 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] ·
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
buildHopyoAll,
|
||||
deleteHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
type HopyoBrief,
|
||||
type HopyoBuilt,
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav";
|
||||
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
|
||||
import { leftButton } from "./M02_MasterTemplete_Hopyo_Left";
|
||||
import { formatPrice } from "../common_util/common_util_money";
|
||||
@@ -81,7 +82,9 @@ export interface HopyoSheetOptions {
|
||||
}
|
||||
|
||||
const ILWI = "일위대가";
|
||||
const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate)
|
||||
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 =>
|
||||
@@ -139,7 +142,12 @@ 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}`;
|
||||
if (opened.has(id)) return opened.get(id)!(opt);
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let nav: Nav.SheetNav | null = null;
|
||||
let detail = ""; // 단가산출 상세 시트 id
|
||||
let above: Element[] = []; // 앞으로 꺼냈을 때 원래 위에 있던 창
|
||||
let closed = false;
|
||||
let briefs: HopyoBrief[] = [];
|
||||
let spans: HopyoSpan[] = [];
|
||||
@@ -193,6 +201,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const finish = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
opened.delete(id);
|
||||
nav?.destroy();
|
||||
sheet?.destroy();
|
||||
opt.onClose?.();
|
||||
};
|
||||
@@ -202,6 +212,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
|
||||
beforeClose: async () => {
|
||||
if (Nav.lower(left, above.splice(0))) return false; // 꺼낸 창 = 닫기 대신 제자리로
|
||||
finish();
|
||||
return true;
|
||||
},
|
||||
@@ -245,6 +256,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
try {
|
||||
await deleteHopyo(opt.scope, opt.key);
|
||||
showToast("지움", "success");
|
||||
Nav.dropGrid();
|
||||
finish();
|
||||
modal.close();
|
||||
opt.onDeleted?.();
|
||||
@@ -271,6 +283,11 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const text = spanText(span, last);
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
opened.set(id, (o) => {
|
||||
above = Nav.raise(left);
|
||||
if (o.단산행 !== undefined && detail) nav?.jumpTo(detail, o.단산행);
|
||||
else if (o.호표 !== undefined) focus(o.호표);
|
||||
});
|
||||
|
||||
/** [수정] — 그 호표 확인 창(줄 누름과 따로) */
|
||||
const editButton = (h: HopyoBrief): HTMLElement => {
|
||||
@@ -339,26 +356,37 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (closed) return;
|
||||
const book = structuredClone(grid.책);
|
||||
fillMissingWidths(book); // 엑셀 폭이 없는 열 = 글 길이로 처음 폭
|
||||
const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0];
|
||||
const ids = Nav.nameTabs(book, grid); // 탭 글 통일(779-4)
|
||||
const first = book.시트.find((s) => s.id === ids.일위대가표 || s.이름 === ILWI) ?? book.시트[0];
|
||||
if (!first) return void left.replaceChildren(note("시트 없음"));
|
||||
ilwi = first.id;
|
||||
detail = ids.단가산출상세 ?? "";
|
||||
last = lastColumn(first);
|
||||
book.활성 = first.id;
|
||||
left.replaceChildren();
|
||||
const lastOf = (sid: string): string =>
|
||||
lastColumn(book.시트.find((x) => x.id === sid) ?? first);
|
||||
const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left };
|
||||
nav = ids.일위대가표 ? Nav.createSheetNav(more) : null;
|
||||
if (nav) warn.after(nav.root);
|
||||
sheet = createSpreadsheet(left, book, {
|
||||
readOnly: true,
|
||||
칠: nav?.fill,
|
||||
onSelect: (s) => {
|
||||
at = s;
|
||||
nav?.at(s);
|
||||
if (s.시트 !== ilwi) return;
|
||||
const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0]));
|
||||
if (no !== null && no !== current) mark(no);
|
||||
},
|
||||
});
|
||||
// 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈)
|
||||
nav?.marks(briefs);
|
||||
// 머리 행 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈 · 줄 행은 그 줄 그대로)
|
||||
left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => {
|
||||
if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return;
|
||||
const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0]));
|
||||
if (no !== null) focus(no);
|
||||
const row = Number(/\d+/.exec(at.칸)?.[0]);
|
||||
const no = hopyoAtRow(spans, row);
|
||||
if (no !== null && spans.find((h) => h.번 === no)?.행[0] === row) focus(no);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +399,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
briefs = next.호표;
|
||||
paintHead(next);
|
||||
paintList();
|
||||
nav?.marks(briefs);
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
}
|
||||
@@ -379,7 +408,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
try {
|
||||
const [file, grid] = await Promise.all([
|
||||
readHopyo(opt.scope, opt.key),
|
||||
readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
|
||||
Nav.gridOf(id, opt.scope, opt.key).catch((error: unknown) => new Error(why(error))),
|
||||
]);
|
||||
if (closed) return;
|
||||
briefs = file.호표;
|
||||
@@ -391,7 +420,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
|
||||
await mountSheet(grid);
|
||||
const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행);
|
||||
if (rows) DANSAN.some((name) => sheet?.select(rows, name));
|
||||
if (rows) [detail, ...DANSAN].some((name) => name && sheet?.select(rows, name));
|
||||
else if (current !== null) focus(current);
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_SheetNav.ts
|
||||
* 엑셀 보기 창 세 보기 막대(PLAN 779-4 · 779-6 · 요구서 621 3장 4 · 5) — 범례(부품 `createLegend`) ·
|
||||
* 머리 막대 = [◂ 뒤로] · 지금 고른 행에 맞는 뛰기 단추(▸ 단가산출 상세 · ▸ 단가산출표 · ▸ 제N호표 · ▸ 부르는 줄 i/n) ·
|
||||
* 원본 시트 이름 · 그 행 칠 까닭 글(블록 「예상」 가름).
|
||||
* · 탭 = 서버 역할 id(779-1) 시트를 같은 글(일위대가표 · 단가산출표 · 단가산출 상세)로 · 원본 이름은 막대
|
||||
* · 칠 = `logic/M02_MasterTemplete_Hopyo_SheetMap` 이 셈 · 격자 옵션 `칠`(779-3) 로 보이는 칸만 · A 열 ~ 끝 열 · 띠는 A 열만
|
||||
* · 다시 칠 = 목록 다시 읽음(`marks`) · 확인 창 캐시 바뀜(`HOPYO_DRAFT`) · 만들기(`HOPYO_BUILT` → 목록 · 블록 다시 읽음)
|
||||
* · 블록을 여러 줄이 부르면 [▸ 부르는 줄] 이 차례로 돌림 · 뛸 때마다 그 앞 자리를 쌓고 [◂ 뒤로] 가 꺼냄 · 두 번 누름 = 첫 단추
|
||||
* · 779-6 — 마지막 격자 다시 씀(`gridOf`) · 열린 창 앞으로 꺼냄 · 닫기 = 제자리로(`raise` · `lower`)
|
||||
* 블록 재료 = 서버 `GET …/hopyo-marks/{키}`(779-2 · 읽기만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { call } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { draftKey, hasDraft, HOPYO_BUILT, loadDraft } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import {
|
||||
at,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
type HopyoGrid,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
indexOf,
|
||||
jumpsAt,
|
||||
paintOf,
|
||||
ROLES,
|
||||
type BlockMark,
|
||||
type BriefMarks,
|
||||
type GridMore,
|
||||
type Jump,
|
||||
type Mark,
|
||||
type Role,
|
||||
} from "./logic/M02_MasterTemplete_Hopyo_SheetMap";
|
||||
|
||||
/** 확인 창 캐시를 고쳤음(document 알림 · `Hopyo_Check.saveDraft` 가 같은 글로 냄) — 엑셀 보기 창 다시 칠 */
|
||||
export const HOPYO_DRAFT = "m02-hopyo-draft";
|
||||
const TAB = { 일위대가표: "M02_SheetIlwi", 단가산출표: "M02_SheetList", 단가산출상세: "M02_SheetDetail" } as const; // prettier-ignore
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
export const readHopyoMarks = async (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
to: HopyoScope,
|
||||
): Promise<BlockMark[]> => {
|
||||
const want = `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}`;
|
||||
return (await call<{ 블록: BlockMark[] }>(at(scope, `/hopyo-marks/${enc(key)}${want}`))).블록;
|
||||
};
|
||||
|
||||
/** 마지막 격자(최대 1 MB · 779-6) — 같은 파일 창을 다시 열면 다시 안 받음 · 못 받음은 안 둠 */
|
||||
let kept: { id: string; got: Promise<HopyoGrid> } | null = null;
|
||||
export function gridOf(id: string, scope: HopyoScope, key: string): Promise<HopyoGrid> {
|
||||
if (kept?.id !== id) kept = { id, got: readHopyoGrid(scope, key) };
|
||||
const got = kept.got;
|
||||
got.catch(() => kept?.got === got && (kept = null));
|
||||
return got;
|
||||
}
|
||||
export const dropGrid = (): void => void (kept = null);
|
||||
|
||||
/** 창(모달 바탕)을 맨 앞으로 — 원래 위에 있던 것들(제자리 표시) · 이미 맨 앞이면 [] */
|
||||
export function raise(node: Element): Element[] {
|
||||
const back = node.closest(".ui-modal__backdrop");
|
||||
let above = back?.nextElementSibling ?? null;
|
||||
while (above && !above.matches(".ui-modal__backdrop")) above = above.nextElementSibling;
|
||||
if (!back || !above) return [];
|
||||
document.body.append(back);
|
||||
return [above];
|
||||
}
|
||||
/** 꺼낸 창을 제자리로 — 했으면 참(닫기 대신) */
|
||||
export function lower(node: Element, above: Element[]): boolean {
|
||||
const back = node.closest(".ui-modal__backdrop");
|
||||
if (!back || !above[0]?.isConnected) return false;
|
||||
document.body.insertBefore(back, above[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 역할 시트 탭 글 통일 — 역할 → 시트 id */
|
||||
export function nameTabs(book: Workbook, grid: HopyoGrid): Partial<Record<Role, string>> {
|
||||
const ids = (grid as HopyoGrid & GridMore).보기 ?? {};
|
||||
for (const r of ROLES) {
|
||||
const s = book.시트.find((x) => x.id === ids[r]);
|
||||
if (s) s.이름 = L(TAB[r]);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export interface SheetNavOptions {
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
target: HopyoScope;
|
||||
grid: HopyoGrid;
|
||||
/** 그 시트 끝 열 글자(범위 A1 · 칠 열 끝) */
|
||||
last: (sheet: string) => string;
|
||||
sheet: () => SpreadsheetHandle | null;
|
||||
/** 두 번 누름을 들을 자리(시트 칸) */
|
||||
host: HTMLElement;
|
||||
}
|
||||
|
||||
const toCol = (letters: string): number =>
|
||||
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
|
||||
|
||||
export function createSheetNav(opt: SheetNavOptions) {
|
||||
const grid = opt.grid as HopyoGrid & GridMore;
|
||||
const ix = indexOf(grid);
|
||||
const roleOf = new Map(Object.entries(ix.ids).map(([r, id]) => [id, r as Role]));
|
||||
let briefs: BriefMarks[] = [];
|
||||
let blocks: BlockMark[] = [];
|
||||
let paint = new Map<string, Map<number, Mark>>();
|
||||
let here = { 시트: ix.ids.일위대가표 ?? "", 범위: "", 행: 0 };
|
||||
const back: { 시트: string; 범위: string }[] = [];
|
||||
const turn = new Map<number, number>(); // 블록 → 다음 부르는 줄 차례
|
||||
const lastCol = new Map<string, number>();
|
||||
const colOf = (sheet: string): number =>
|
||||
lastCol.get(sheet) ?? lastCol.set(sheet, toCol(opt.last(sheet))).get(sheet)!;
|
||||
const draftOf = (no: number) => {
|
||||
const k = draftKey(opt.scope, opt.key, no);
|
||||
return hasDraft(k) ? loadDraft(k) : null;
|
||||
};
|
||||
const go = (range: string, sheet: string): void => void opt.sheet()?.select(range, sheet);
|
||||
|
||||
const buttons = el("div", { className: "ui-toolbar m02-hopyo__bar" });
|
||||
const source = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
|
||||
const why = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
|
||||
why.setAttribute("data-hopyo-why", "");
|
||||
const legend = createLegend([
|
||||
{ label: L("M02_ToneSuccess"), color: "var(--color-success)" },
|
||||
{ label: L("M02_ToneWarning"), color: "var(--color-warning)" },
|
||||
{ label: L("M02_ToneInfo"), color: "var(--color-info)" },
|
||||
{ label: L("M02_ToneDanger"), color: "var(--color-danger)" },
|
||||
{ label: L("M02_ToneMuted"), color: "var(--color-text-muted)" },
|
||||
{ label: L("M02_ToneMine"), color: "var(--color-accent)", kind: "line" },
|
||||
]);
|
||||
const bar = el("div", { className: "ui-toolbar m02-hopyo__bar", children: [buttons, source, why] }); // prettier-ignore
|
||||
const root = el("div", { className: "m02-hopyo-sheet__nav", children: [bar, legend] });
|
||||
root.setAttribute("data-hopyo-nav", "");
|
||||
|
||||
/** 뛰기 — 지금 자리를 쌓고 감 · 행 하나면 [n, n] */
|
||||
function jumpTo(sheet: string, rows: number | [number, number]): void {
|
||||
const [a, b] = typeof rows === "number" ? [rows, rows] : rows;
|
||||
if (here.시트) back.push({ 시트: here.시트, 범위: here.범위 || `A${here.행 || 1}` });
|
||||
go(`A${a}:${opt.last(sheet)}${b}`, sheet);
|
||||
}
|
||||
|
||||
const word = (j: Jump, i: number): string => {
|
||||
const key = { 상세: "M02_SheetToDetail", 목록: "M02_SheetToList", 호표: "M02_SheetToHopyo", 부름: "M02_SheetToCaller" } as const; // prettier-ignore
|
||||
return L(key[j.갈래])
|
||||
.replace("{n}", String(j.번))
|
||||
.replace("{i}", String(i + 1))
|
||||
.replace("{all}", String(j.곳.length));
|
||||
};
|
||||
|
||||
function paintBar(): void {
|
||||
const backBtn = createButton({
|
||||
label: L("M02_SheetBack"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const to = back.pop();
|
||||
if (to) go(to.범위, to.시트);
|
||||
},
|
||||
});
|
||||
backBtn.disabled = !back.length;
|
||||
backBtn.setAttribute("data-hopyo-back", "");
|
||||
const jumps = jumpsAt(ix, here.시트, here.행).map((j) => {
|
||||
const self = j.곳.findIndex((s) => s.시트 === here.시트 && s.행[0] === here.행);
|
||||
const next = self >= 0 ? self + 1 : (turn.get(j.번) ?? 0); // 부르는 줄 위 = 그 다음
|
||||
const i = j.갈래 === "부름" ? next % j.곳.length : 0;
|
||||
const b = createButton({
|
||||
label: word(j, i),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const mine = here.시트 === ix.ids.일위대가표 ? (ix.callers.get(j.번) ?? []) : [];
|
||||
const from = mine.findIndex((l) => l.행 === here.행); // 부르는 줄에서 옴 = 그 다음부터
|
||||
turn.set(j.번, j.갈래 === "부름" ? i + 1 : from + 1);
|
||||
jumpTo(j.곳[i].시트, j.곳[i].행);
|
||||
},
|
||||
});
|
||||
b.setAttribute("data-hopyo-jump", j.갈래);
|
||||
return b;
|
||||
});
|
||||
buttons.replaceChildren(backBtn, ...jumps);
|
||||
const role = roleOf.get(here.시트);
|
||||
const name = role ? grid.원본?.[role] : undefined;
|
||||
source.textContent = name ? L("M02_SheetSource").replace("{name}", name) : "";
|
||||
why.textContent = paint.get(here.시트)?.get(here.행)?.글 ?? "";
|
||||
}
|
||||
|
||||
function repaint(): void {
|
||||
paint = paintOf(ix, briefs, blocks, draftOf);
|
||||
opt.sheet()?.repaint();
|
||||
paintBar();
|
||||
}
|
||||
|
||||
async function readBlocks(): Promise<void> {
|
||||
blocks = await readHopyoMarks(opt.scope, opt.key, opt.target).catch(() => blocks);
|
||||
repaint();
|
||||
}
|
||||
const onDraft = (): void => repaint();
|
||||
const onBuilt = (): void => {
|
||||
void readHopyo(opt.scope, opt.key).then((v) => (briefs = v.호표 as BriefMarks[]), () => {}).then(readBlocks); // prettier-ignore
|
||||
};
|
||||
const onTwice = (): void => root.querySelector<HTMLButtonElement>("[data-hopyo-jump]")?.click();
|
||||
document.addEventListener(HOPYO_DRAFT, onDraft);
|
||||
document.addEventListener(HOPYO_BUILT, onBuilt);
|
||||
opt.host.addEventListener("dblclick", onTwice);
|
||||
paintBar();
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 격자 옵션 `칠` — 시트 · 행 · 열(0부터) */
|
||||
fill(sheet: string, r: number, c: number): string | null {
|
||||
const m = c <= colOf(sheet) ? paint.get(sheet)?.get(r + 1) : undefined; // A 열부터(STmate 명칭 = A)
|
||||
return !m ? null : c === 0 ? m.tone : m.tone.replace(" mine", "");
|
||||
},
|
||||
/** 고름 바뀜 — 맥락 단추 다시 */
|
||||
at(sel: { 시트: string; 칸: string; 범위: string }): void {
|
||||
here = { 시트: sel.시트, 범위: sel.범위, 행: Number(/\d+/.exec(sel.칸)?.[0] ?? 0) };
|
||||
paintBar();
|
||||
},
|
||||
/** 목록 다시 읽음(줄상태 · 만든) — 블록 판정도 다시(셈 중 → 끝) */
|
||||
marks(next: unknown[]): void {
|
||||
briefs = next as BriefMarks[];
|
||||
repaint();
|
||||
void readBlocks();
|
||||
},
|
||||
jumpTo,
|
||||
destroy(): void {
|
||||
document.removeEventListener(HOPYO_DRAFT, onDraft);
|
||||
document.removeEventListener(HOPYO_BUILT, onBuilt);
|
||||
opt.host.removeEventListener("dblclick", onTwice);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SheetNav = ReturnType<typeof createSheetNav>;
|
||||
@@ -0,0 +1,225 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts
|
||||
* 엑셀 보기 창 세 보기 칠 · 뛸 곳(PLAN 779-4 · 요구서 621 3장 4 · 5) — 순수 셈.
|
||||
* · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(779-2 `Match.view`) ·
|
||||
* 블록 `[{번, 판정, SK, 갈래, 못옮김}]`(779-2 `M02_Hopyo_Marks` — 칠 꼴 표가 그 머리) · 확인 창 캐시 고름
|
||||
* · 칠 = success 이어짐 · warning 이어짐 · 엑셀과 다름 · info 고를 것 · danger 못 이음 · muted 밖 + mine(내가 고름 띠)
|
||||
* · 「이어짐」 = 확인 창과 한 규칙(`Hopyo_Check` `isChecked && checkable` · 캐시 고름이 이김) — 캐시에 대상이 없으면
|
||||
* 서버 체크 제안(서버가 배 확실할 때만 켬)을 그대로 믿음
|
||||
* · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖
|
||||
* · 뛸 곳 = 일위대가 단산 줄 → 상세 블록 · 목록 행 · (여럿이면) 다른 부르는 줄 / 일위 줄 → 하위 호표 / 목록 행 → 상세 · 부르는 줄 / 상세 → 부르는 줄 · 목록
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
export type Role = "일위대가표" | "단가산출표" | "단가산출상세";
|
||||
export const ROLES: Role[] = ["일위대가표", "단가산출표", "단가산출상세"];
|
||||
export type Tone = "success" | "warning" | "info" | "danger" | "muted";
|
||||
type Rows = [number, number];
|
||||
|
||||
/** 격자 응답 덧 칸(779-1) — 옛 파일엔 없음 */
|
||||
export interface GridLine {
|
||||
번: number;
|
||||
차례: number;
|
||||
행: number | null;
|
||||
단산?: { 번: number; 행: Rows };
|
||||
일위?: { 번: number; 행: Rows };
|
||||
}
|
||||
export interface GridMore {
|
||||
호표: { 번: number; 행: Rows }[];
|
||||
단산: { 번: number; 행: Rows }[];
|
||||
보기?: Partial<Record<Role, string>>;
|
||||
원본?: Partial<Record<Role, string>>;
|
||||
목록?: { 호표: { 번: number; 행: number }[]; 단산: { 번: number; 행: number }[] };
|
||||
줄?: GridLine[];
|
||||
}
|
||||
/** 목록 호표 덧 칸(779-2) — `줄상태 [[차례, 상태, 체크 제안]]` · `만든` SC 키 */
|
||||
export interface BriefMarks {
|
||||
번: number;
|
||||
줄상태?: [number, string, boolean][];
|
||||
만든?: string | null;
|
||||
}
|
||||
export interface BlockMark {
|
||||
번: number;
|
||||
판정: string | null;
|
||||
SK: string | null;
|
||||
갈래: string | null;
|
||||
못옮김: unknown[];
|
||||
}
|
||||
/** 확인 창 캐시 고름(`Hopyo_Check` `CheckDraft` 중 칠에 쓰는 것) */
|
||||
export interface PickDraft {
|
||||
체크: Record<number, boolean>;
|
||||
대상: Record<number, { 배: number | null; 비율?: unknown }[]>;
|
||||
뺌: Record<number, string>;
|
||||
}
|
||||
export interface Mark {
|
||||
tone: string;
|
||||
글: string;
|
||||
}
|
||||
/** 뛸 곳 — 시트 id · 행 [시작, 끝] · 부름 = 여럿(차례로 돌림) */
|
||||
export interface Spot {
|
||||
시트: string;
|
||||
행: Rows;
|
||||
}
|
||||
export interface Jump {
|
||||
갈래: "상세" | "목록" | "호표" | "부름";
|
||||
번: number;
|
||||
곳: Spot[];
|
||||
}
|
||||
|
||||
const RANK: Record<Tone, number> = { muted: 0, success: 1, warning: 2, info: 3, danger: 4 };
|
||||
export const WORD: Record<Tone, string> = {
|
||||
success: "이어짐",
|
||||
warning: "이어짐 · 엑셀과 다름",
|
||||
info: "고를 것",
|
||||
danger: "못 이음",
|
||||
muted: "밖",
|
||||
};
|
||||
|
||||
/** 줄 칠 — `state` 서버 상태 · `check` 서버 체크 제안 · `d` 캐시 고름 · `at` 줄 차례 */
|
||||
export function lineTone(
|
||||
state: string,
|
||||
check: boolean,
|
||||
d: PickDraft | null,
|
||||
at: number,
|
||||
): { tone: Tone; mine: boolean } {
|
||||
const parts = d?.대상[at];
|
||||
const mine = parts !== undefined || !!d?.뺌[at];
|
||||
if (d?.뺌[at] || state === "뺌") return { tone: "muted", mine };
|
||||
const ok = !parts
|
||||
? true // 캐시 대상 없음 = 서버 제안 대상(체크 제안은 배 확실할 때만)
|
||||
: state === "%"
|
||||
? !!parts[0]?.비율
|
||||
: parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
|
||||
const on = (d?.체크[at] ?? check) && ok;
|
||||
if (state === "%") return { tone: on ? "success" : "muted", mine };
|
||||
if (on) return { tone: state === "다른 점" && !parts ? "warning" : "success", mine };
|
||||
return { tone: state === "없음" || state === "노임" ? "danger" : "info", mine };
|
||||
}
|
||||
|
||||
/** 가장 나쁜 칠(밖만이면 밖) */
|
||||
export const worst = (tones: Tone[]): Tone =>
|
||||
tones.reduce<Tone>((a, b) => (RANK[b] > RANK[a] ? b : a), "muted");
|
||||
|
||||
/** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 밖 */
|
||||
export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } {
|
||||
if (!m) return { tone: "muted", 글: "안 불림" };
|
||||
if (m.SK) {
|
||||
const lost = m.못옮김.length;
|
||||
const tone: Tone = lost ? "danger" : m.갈래 === "SK" ? "warning" : "success";
|
||||
return { tone, 글: `${m.SK} · ${m.갈래 ?? ""}${lost ? ` · 못옮김 ${lost}` : ""}` };
|
||||
}
|
||||
if (!m.판정) return { tone: "muted", 글: "판정 없음" };
|
||||
const tone: Tone = m.판정 === "맞음" ? "success" : m.판정 === "다른 점" ? "warning" : "info";
|
||||
return { tone, 글: `예상 — 부르는 첫 줄 ${m.판정}` };
|
||||
}
|
||||
|
||||
/** 보기 셋 찾기표 */
|
||||
export interface SheetIndex {
|
||||
ids: Partial<Record<Role, string>>;
|
||||
lineAt: Map<number, GridLine>;
|
||||
hopyo: Map<number, Rows>;
|
||||
block: Map<number, Rows>;
|
||||
listRow: Map<number, number>;
|
||||
listAt: Map<number, number>;
|
||||
callers: Map<number, GridLine[]>;
|
||||
}
|
||||
|
||||
export function indexOf(g: GridMore): SheetIndex {
|
||||
const callers = new Map<number, GridLine[]>();
|
||||
for (const l of g.줄 ?? [])
|
||||
if (l.단산 && l.행) callers.set(l.단산.번, [...(callers.get(l.단산.번) ?? []), l]);
|
||||
const list = g.목록?.단산 ?? [];
|
||||
return {
|
||||
ids: g.보기 ?? {},
|
||||
lineAt: new Map((g.줄 ?? []).flatMap((l) => (l.행 ? [[l.행, l] as const] : []))),
|
||||
hopyo: new Map(g.호표.map((h) => [h.번, h.행])),
|
||||
block: new Map(g.단산.map((b) => [b.번, b.행])),
|
||||
listRow: new Map(list.map((x) => [x.번, x.행])),
|
||||
listAt: new Map(list.map((x) => [x.행, x.번])),
|
||||
callers,
|
||||
};
|
||||
}
|
||||
|
||||
/** 행 범위 안 번(호표 · 블록) */
|
||||
export const numAt = (spans: Map<number, Rows>, row: number): number | null => {
|
||||
for (const [no, [a, b]] of spans) if (a <= row && row <= b) return no;
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 시트 · 행에서 뛸 곳 — 없으면 [] */
|
||||
export function jumpsAt(ix: SheetIndex, sheet: string, row: number): Jump[] {
|
||||
const { 일위대가표: ilwi, 단가산출표: list, 단가산출상세: detail } = ix.ids;
|
||||
const detailOf = (no: number): Jump[] => {
|
||||
const rows = ix.block.get(no);
|
||||
return detail && rows ? [{ 갈래: "상세", 번: no, 곳: [{ 시트: detail, 행: rows }] }] : [];
|
||||
};
|
||||
const listOf = (no: number): Jump[] => {
|
||||
const at = ix.listRow.get(no);
|
||||
return list && at ? [{ 갈래: "목록", 번: no, 곳: [{ 시트: list, 행: [at, at] }] }] : [];
|
||||
};
|
||||
const callersOf = (no: number): Jump[] => {
|
||||
const lines = ix.callers.get(no) ?? [];
|
||||
const 곳 = lines.map((l): Spot => ({ 시트: ilwi!, 행: [l.행!, l.행!] }));
|
||||
return ilwi && 곳.length ? [{ 갈래: "부름", 번: no, 곳 }] : [];
|
||||
};
|
||||
if (sheet === ilwi) {
|
||||
const l = ix.lineAt.get(row);
|
||||
if (l?.단산) {
|
||||
const more = (ix.callers.get(l.단산.번)?.length ?? 0) > 1 ? callersOf(l.단산.번) : [];
|
||||
return [...detailOf(l.단산.번), ...listOf(l.단산.번), ...more]; // 같은 블록 다른 부르는 줄
|
||||
}
|
||||
if (l?.일위) return [{ 갈래: "호표", 번: l.일위.번, 곳: [{ 시트: ilwi, 행: l.일위.행 }] }];
|
||||
return [];
|
||||
}
|
||||
if (sheet === list) {
|
||||
const no = ix.listAt.get(row);
|
||||
return no === undefined ? [] : [...detailOf(no), ...callersOf(no)];
|
||||
}
|
||||
if (sheet === detail) {
|
||||
const no = numAt(ix.block, row);
|
||||
return no === null ? [] : [...callersOf(no), ...listOf(no)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 칠 표 — 시트 id → 행(1부터) → {꼴, 글} · `draftOf(번)` = 그 호표 캐시 고름(없으면 null) */
|
||||
export function paintOf(
|
||||
ix: SheetIndex,
|
||||
briefs: BriefMarks[],
|
||||
blocks: BlockMark[],
|
||||
draftOf: (no: number) => PickDraft | null,
|
||||
): Map<string, Map<number, Mark>> {
|
||||
const out = new Map<string, Map<number, Mark>>();
|
||||
const put = (sheet: string | undefined, row: number, m: Mark): void => {
|
||||
if (sheet) (out.get(sheet) ?? out.set(sheet, new Map()).get(sheet)!).set(row, m);
|
||||
};
|
||||
const { 일위대가표: ilwi, 단가산출표: list, 단가산출상세: detail } = ix.ids;
|
||||
const rowOf = new Map<string, number>(); // 「번:차례」 → 일위대가 행
|
||||
for (const l of ix.lineAt.values()) rowOf.set(`${l.번}:${l.차례}`, l.행!);
|
||||
for (const h of briefs) {
|
||||
if (!h.줄상태) continue;
|
||||
const d = draftOf(h.번);
|
||||
const tones: Tone[] = [];
|
||||
for (const [at, state, check] of h.줄상태) {
|
||||
const { tone, mine } = lineTone(state, check, d, at);
|
||||
tones.push(tone);
|
||||
const row = rowOf.get(`${h.번}:${at}`);
|
||||
const 글 = `제${h.번}호표 줄 ${at + 1} · ${state} → ${WORD[tone]}${mine ? " · 내가 고름" : ""}`;
|
||||
if (row) put(ilwi, row, { tone: mine ? `${tone} mine` : tone, 글 });
|
||||
}
|
||||
const head = ix.hopyo.get(h.번)?.[0];
|
||||
const tone = worst(tones);
|
||||
const made = h.만든 ? ` · 만듦 ${h.만든}` : "";
|
||||
if (head && !out.get(ilwi ?? "")?.has(head))
|
||||
put(ilwi, head, { tone, 글: `제${h.번}호표 · 가장 나쁜 줄 ${WORD[tone]}${made}` });
|
||||
}
|
||||
const by = new Map(blocks.map((b) => [b.번, b]));
|
||||
for (const [no, [a, b]] of ix.block) {
|
||||
const { tone, 글 } = blockTone(by.get(no));
|
||||
const m = { tone, 글: `단산${no} · ${WORD[tone]} · ${글}` };
|
||||
for (let r = a; r <= b; r += 1) put(detail, r, m);
|
||||
const at = ix.listRow.get(no);
|
||||
if (at) put(list, at, m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const { loadTs } = makeLoader({
|
||||
},
|
||||
});
|
||||
global.sessionStorage = memoryStorage();
|
||||
global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4) 대역
|
||||
|
||||
(async () => {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* 779-4 엑셀 보기 창 칠 · 뛸 곳 헬퍼 — `logic/M02_MasterTemplete_Hopyo_SheetMap.ts`(순수)를 트랜스파일해 Node 로 돌림.
|
||||
* stdin = {grid(책 뺀 격자), briefs, blocks, drafts{번: 캐시}, tones[[상태, 체크, 캐시, 차례]], at[[시트, 행]]}
|
||||
* stdout = {tones[{tone, mine}], blocks[{tone, 글}], jumps[[Jump]], paint{시트: {행: 꼴}}} JSON. */
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { loadTs } = makeLoader({ root: ROOT });
|
||||
const map = loadTs(
|
||||
path.join(ROOT, "M02_MasterTemplete", "logic", "M02_MasterTemplete_Hopyo_SheetMap.ts"),
|
||||
);
|
||||
|
||||
let raw = "";
|
||||
process.stdin.on("data", (c) => (raw += c));
|
||||
process.stdin.on("end", () => {
|
||||
const input = JSON.parse(raw);
|
||||
const out = {
|
||||
tones: (input.tones ?? []).map(([s, c, d, at]) => map.lineTone(s, c, d, at)),
|
||||
blocks: (input.blockTones ?? []).map((m) => map.blockTone(m ?? undefined)),
|
||||
};
|
||||
if (input.grid) {
|
||||
const ix = map.indexOf(input.grid);
|
||||
out.jumps = (input.at ?? []).map(([sheet, row]) => map.jumpsAt(ix, sheet, row));
|
||||
const paint = map.paintOf(
|
||||
ix,
|
||||
input.briefs ?? [],
|
||||
input.blocks ?? [],
|
||||
(no) => (input.drafts ?? {})[no] ?? null,
|
||||
);
|
||||
out.paint = Object.fromEntries(
|
||||
[...paint].map(([sheet, rows]) => [
|
||||
sheet,
|
||||
Object.fromEntries([...rows].map(([r, m]) => [r, m.tone])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
});
|
||||
@@ -47,6 +47,7 @@ global.sessionStorage = {
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
global.document = { dispatchEvent: () => true }; // 캐시 고침 알림(779-4) 대역
|
||||
|
||||
let out0 = null;
|
||||
(async () => {
|
||||
|
||||
@@ -44,8 +44,8 @@ def test_줄상태_수_줄_수_확인창_같음(client):
|
||||
assert check == line["맞춤"]["체크"]
|
||||
assert h["만든"] is None # 확인된 사전조합 전
|
||||
assert [x[0] for x in h["줄상태"]] == list(range(len(h["줄상태"])))
|
||||
# 목록 = 줄 맞춤 `full=False`(덧 셈 0) — 이름 후보 안 찾은 줄(없음)은 「후보」로 옴
|
||||
assert set(diff) <= {("후보", "없음")}, diff
|
||||
# 779-4 — 목록 「후보」 중 이름 후보 안 찾은 줄은 다시 셈 → 확인 창과 모두 같음(「없음」 = 빨강)
|
||||
assert diff == {}, diff
|
||||
|
||||
|
||||
def test_marks_읽기만_판정_캐시_같음_SK(client):
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""779-4 엑셀 보기 창 칠 · 뛸 곳(`logic/M02_MasterTemplete_Hopyo_SheetMap.ts` · Node) —
|
||||
줄 칠 꼴 표(「이어짐」 = 확인 창 체크 규칙 · 캐시가 이김 · 「없음」 = 빨강) · 블록 칠(만든 SK › 판정 「예상」) ·
|
||||
실물 두 형식(북부청 임도 EST · 동부청 영월 STmate): 칠 행 수 = 줄 수 · 머리 행 = 가장 나쁜 칠 ·
|
||||
단산 줄 → 단가산출 상세(역할 id · STmate 「단가산출근거」) · 목록 행 · 부르는 줄 차례."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Grid as hg
|
||||
from resources.tester.helper_m02_hopyo_golden import FILES
|
||||
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl")
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STATES = ("맞음", "다른 점", "후보", "없음", "%", "노임", "별칭", "뺌")
|
||||
|
||||
|
||||
def _node(payload: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_779_4_sheet_map.cjs")],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _d(체크=None, 대상=None, 뺌=None) -> dict:
|
||||
return {"체크": 체크 or {}, "대상": 대상 or {}, "뺌": 뺌 or {}}
|
||||
|
||||
|
||||
def test_줄_칠_꼴표():
|
||||
rows = [
|
||||
(("맞음", True, None, 0), ("success", False)),
|
||||
(("별칭", True, None, 0), ("success", False)),
|
||||
(("호표", True, None, 0), ("success", False)),
|
||||
(("다른 점", True, None, 0), ("warning", False)),
|
||||
(("다른 점", False, None, 0), ("info", False)),
|
||||
(("후보", False, None, 0), ("info", False)),
|
||||
(("호표", False, None, 0), ("info", False)),
|
||||
(("없음", False, None, 0), ("danger", False)), # 확인 창 「없음」 = 못 이음
|
||||
(("노임", False, None, 0), ("danger", False)),
|
||||
(("%", False, None, 0), ("muted", False)),
|
||||
(("뺌", False, None, 0), ("muted", False)),
|
||||
# 캐시가 이김 — 끈 맞음 = 고를 것 · 고른 대상 + 체크 = 이어짐(내가 고름 띠)
|
||||
(("맞음", True, _d({"0": False}), 0), ("info", False)),
|
||||
(("없음", False, _d({"0": True}, {"0": [{"배": 1}]}), 0), ("success", True)),
|
||||
(("다른 점", False, _d({"0": True}, {"0": [{"배": 2}]}), 0), ("success", True)),
|
||||
(("없음", False, _d({"0": True}, {"0": [{"배": None}]}), 0), ("danger", True)), # 배 없음
|
||||
(("후보", False, _d({"3": True}, {"3": []}), 3), ("info", True)), # 대상 없음 = 못 체크
|
||||
(("맞음", True, _d(뺌={"0": "로직없음"}), 0), ("muted", True)),
|
||||
(("%", False, _d({"0": True}, {"0": [{"배": 1, "비율": "x"}]}), 0), ("success", True)),
|
||||
(("%", False, _d({"0": True}, {"0": [{"배": 1}]}), 0), ("muted", True)),
|
||||
(("맞음", True, _d({"5": False}), 0), ("success", False)), # 다른 줄 캐시 = 서버 제안
|
||||
]
|
||||
got = _node({"tones": [list(a) for a, _ in rows]})["tones"]
|
||||
assert [(x["tone"], x["mine"]) for x in got] == [b for _, b in rows]
|
||||
|
||||
|
||||
def test_블록_칠():
|
||||
marks = [
|
||||
None, # 안 불림
|
||||
{"번": 1, "판정": None, "SK": None, "갈래": None, "못옮김": []},
|
||||
{"번": 2, "판정": "맞음", "SK": None, "갈래": None, "못옮김": []},
|
||||
{"번": 3, "판정": "다른 점", "SK": None, "갈래": None, "못옮김": []},
|
||||
{"번": 4, "판정": "후보", "SK": None, "갈래": None, "못옮김": []},
|
||||
{"번": 5, "판정": "후보", "SK": "SK-000009", "갈래": "기초", "못옮김": []},
|
||||
{"번": 6, "판정": "맞음", "SK": "SK-000010", "갈래": "SK", "못옮김": []},
|
||||
{"번": 7, "판정": "맞음", "SK": "SK-000011", "갈래": "SK", "못옮김": [{"이름": "x"}]},
|
||||
]
|
||||
got = _node({"blockTones": marks})["blocks"]
|
||||
assert [x["tone"] for x in got] == [
|
||||
"muted",
|
||||
"muted",
|
||||
"success",
|
||||
"warning",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
]
|
||||
assert got[2]["글"].startswith("예상") and got[5]["글"].startswith("SK-000009")
|
||||
assert "못옮김 1" in got[7]["글"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=["북부청임도", "동부청영월"])
|
||||
def real(request):
|
||||
path = FILES[request.param]
|
||||
data = path.read_bytes()
|
||||
parsed = hx.read_book(path.name, data)
|
||||
grid = hg.read(path.name, data, parsed)
|
||||
grid.pop("책")
|
||||
return parsed, grid
|
||||
|
||||
|
||||
def test_실물_칠_행_수_머리_행(real):
|
||||
parsed, grid = real
|
||||
briefs, n = [], 0
|
||||
for h in parsed["호표"]:
|
||||
states = []
|
||||
for x in h["줄"]:
|
||||
states.append([x["차례"], STATES[n % len(STATES)], n % 3 == 0])
|
||||
n += 1
|
||||
briefs.append({"번": h["번"], "줄상태": states})
|
||||
got = _node({"grid": grid, "briefs": briefs, "blocks": []})
|
||||
ilwi = grid["보기"]["일위대가표"]
|
||||
paint = {int(k): v for k, v in got["paint"][ilwi].items()}
|
||||
line_rows = {x["행"] for x in grid["줄"]}
|
||||
assert len(line_rows) == n # 줄 행 1:1
|
||||
assert line_rows <= set(paint) # 칠 칸 행 수 = 줄 상태 수
|
||||
heads = {h["행"][0] for h in grid["호표"]} - line_rows
|
||||
assert set(paint) == line_rows | heads
|
||||
rank = ["muted", "success", "warning", "info", "danger"]
|
||||
for h in grid["호표"]:
|
||||
mine = [paint[x["행"]] for x in grid["줄"] if x["번"] == h["번"]]
|
||||
if mine and h["행"][0] not in line_rows:
|
||||
assert paint[h["행"][0]] == max(mine, key=rank.index), h["번"]
|
||||
nothing = [x for b in briefs for x in b["줄상태"] if x[1] == "없음" and not x[2]]
|
||||
assert nothing and all(
|
||||
paint[r] == "danger"
|
||||
for b in briefs
|
||||
for at, s, c in b["줄상태"]
|
||||
if s == "없음" and not c
|
||||
for r in [next(x["행"] for x in grid["줄"] if (x["번"], x["차례"]) == (b["번"], at))]
|
||||
)
|
||||
# 블록 = 재료 없으면 밖(muted) · 상세 블록 행 · 목록 행 모두 칠
|
||||
detail = {int(k) for k in got["paint"][grid["보기"]["단가산출상세"]]}
|
||||
assert detail == {r for b in grid["단산"] for r in range(b["행"][0], b["행"][1] + 1)}
|
||||
listed = {int(k) for k in got["paint"][grid["보기"]["단가산출표"]]}
|
||||
assert listed == {
|
||||
x["행"] for x in grid["목록"]["단산"] if x["번"] in {b["번"] for b in grid["단산"]}
|
||||
}
|
||||
|
||||
|
||||
def test_실물_뛸_곳(real):
|
||||
parsed, grid = real
|
||||
ids = grid["보기"]
|
||||
calls = [x for x in grid["줄"] if x.get("단산")]
|
||||
kids = [x for x in grid["줄"] if x.get("일위")]
|
||||
listed = {x["번"]: x["행"] for x in grid["목록"]["단산"]}
|
||||
block = {b["번"]: b["행"] for b in grid["단산"]}
|
||||
callers: dict[int, list[int]] = {}
|
||||
for x in calls:
|
||||
callers.setdefault(x["단산"]["번"], []).append(x["행"])
|
||||
many = next(no for no, rows in callers.items() if len(rows) > 1)
|
||||
at = [[ids["일위대가표"], x["행"]] for x in calls + kids]
|
||||
at += [[ids["단가산출표"], listed[many]], [ids["단가산출상세"], block[many][1]]]
|
||||
at += [[ids["단가산출상세"], 1], [ids["일위대가표"], 1]] # 머리 · 블록 밖 = 없음
|
||||
got = _node({"grid": grid, "at": at})["jumps"]
|
||||
for x, jumps in zip(calls, got):
|
||||
no = x["단산"]["번"]
|
||||
first = jumps[0]
|
||||
assert (first["갈래"], first["번"]) == ("상세", no)
|
||||
assert first["곳"] == [{"시트": ids["단가산출상세"], "행": x["단산"]["행"]}]
|
||||
assert jumps[1] == {"갈래": "목록", "번": no, "곳": [{"시트": ids["단가산출표"], "행": [listed[no]] * 2}]} # fmt: skip
|
||||
assert len(jumps) == (3 if len(callers[no]) > 1 else 2)
|
||||
for x, jumps in zip(kids, got[len(calls) :]):
|
||||
assert jumps == [{"갈래": "호표", "번": x["일위"]["번"], "곳": [{"시트": ids["일위대가표"], "행": x["일위"]["행"]}]}] # fmt: skip
|
||||
from_list, from_detail, none1, none2 = got[-4:]
|
||||
assert [j["갈래"] for j in from_list] == ["상세", "부름"]
|
||||
assert [j["갈래"] for j in from_detail] == ["부름", "목록"]
|
||||
assert [s["행"][0] for s in from_detail[0]["곳"]] == callers[many] # 부르는 줄 차례
|
||||
assert none1 == [] and none2 == []
|
||||
if parsed["꼴"] == "STmate": # 역할 id = 「단가산출근거」 시트(이름으로 안 찾음)
|
||||
assert grid["원본"]["단가산출상세"] == "단가산출근거"
|
||||
@@ -549,3 +549,42 @@ def test_372_2_저장은_개인_층에_삭제는_화면_층_권한():
|
||||
assert 'addEventListener("pagehide", leave)' in side and "return { root, leave };" in side
|
||||
panel = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert panel.count("open?.leave?.()") == 1 and panel.count("open.leave?.()") == 1
|
||||
|
||||
|
||||
def test_779_세_보기_칠_뛰기_창_다시_씀():
|
||||
"""779-4 — 탭 = 서버 역할 id(이름으로 안 찾음) · 칠 = 공용 격자 `칠` · 범례 부품 · 색은 공통 값 ·
|
||||
확인 창 캐시 고침 = 다시 칠 알림 · 779-6 — 같은 파일 창은 다시 씀(앞으로 · 제자리) · 격자 다시 안 받음."""
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
nav = _text("M02_MasterTemplete_Hopyo_SheetNav.ts")
|
||||
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
assert "Nav.nameTabs(book, grid)" in sheet and "칠: nav?.fill" in sheet
|
||||
assert "nav?.at(s);" in sheet and "nav?.marks(briefs);" in sheet
|
||||
assert "[detail, ...DANSAN]" in sheet # 단산행 = 역할 id 먼저(STmate 「단가산출근거」)
|
||||
assert "if (opened.has(id)) return opened.get(id)!(opt);" in sheet
|
||||
assert "Nav.gridOf(id, opt.scope, opt.key)" in sheet and "readHopyoGrid" not in sheet
|
||||
assert "above = Nav.raise(left);" in sheet and "Nav.lower(left, above.splice(0))" in sheet
|
||||
assert "createLegend([" in nav and 'kind: "line"' in nav
|
||||
assert re.findall(r'color: "([^"]+)"', nav) == [
|
||||
f"var(--color-{x})"
|
||||
for x in ("success", "warning", "info", "danger", "text-muted", "accent")
|
||||
]
|
||||
assert 'HOPYO_DRAFT = "m02-hopyo-draft"' in nav
|
||||
save = check.split("export function saveDraft(")[1].split("export function dropDraft(")[0]
|
||||
assert 'document.dispatchEvent(new Event("m02-hopyo-draft"));' in save
|
||||
for name in (
|
||||
"M02_MasterTemplete_Hopyo_SheetNav.ts",
|
||||
"logic/M02_MasterTemplete_Hopyo_SheetMap.ts",
|
||||
):
|
||||
text = _text(name)
|
||||
assert (
|
||||
'el("button"' not in text
|
||||
and 'el("input"' not in text
|
||||
and "#" not in text.split("*/", 1)[1]
|
||||
)
|
||||
pure = _text("logic/M02_MasterTemplete_Hopyo_SheetMap.ts").split("*/", 1)[1]
|
||||
for word in ("document", "fetch", "sessionStorage", "import "):
|
||||
assert word not in pure, word
|
||||
assert (
|
||||
len(check.splitlines()) <= 663
|
||||
and len(_text("M02_MasterTemplete_Hopyo_Api.ts").splitlines()) <= 676
|
||||
)
|
||||
|
||||
@@ -347,4 +347,19 @@ export const ui_locales_m2 = {
|
||||
M02_CmpColLook: ["꼴", "Result"],
|
||||
M02_CmpLost: ["못옮김 {n}", "{n} unmoved"],
|
||||
M02_ElemTitle: ["M01 요소 후보", "M01 element candidates"],
|
||||
M02_SheetIlwi: ["일위대가표", "Unit-price sheet"],
|
||||
M02_SheetList: ["단가산출표", "Price basis list"],
|
||||
M02_SheetDetail: ["단가산출 상세", "Price basis detail"],
|
||||
M02_SheetSource: ["원본 시트 「{name}」", "Source sheet “{name}”"],
|
||||
M02_SheetBack: ["◂ 뒤로", "◂ Back"],
|
||||
M02_SheetToDetail: ["▸ 단가산출 상세 · 단산{n}", "▸ Basis detail · {n}"],
|
||||
M02_SheetToList: ["▸ 단가산출표 · 단산{n}", "▸ Basis list · {n}"],
|
||||
M02_SheetToHopyo: ["▸ 제{n}호표", "▸ Sheet {n}"],
|
||||
M02_SheetToCaller: ["▸ 부르는 줄 {i}/{all}", "▸ Calling line {i}/{all}"],
|
||||
M02_ToneSuccess: ["이어짐", "Linked"],
|
||||
M02_ToneWarning: ["이어짐 · 엑셀과 다름", "Linked · differs"],
|
||||
M02_ToneInfo: ["고를 것", "To pick"],
|
||||
M02_ToneDanger: ["못 이음", "Not linked"],
|
||||
M02_ToneMuted: ["밖", "Outside"],
|
||||
M02_ToneMine: ["내가 고름", "My pick"],
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user