From 56f606f331cd5baa9baced2a8f62cfd26c33e62c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:27:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-4=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C=E3=80=8D=20?= =?UTF-8?q?=ED=8C=90=20=C2=B7=20=EB=B3=B8=20=ED=99=94=EB=A9=B4(=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=ED=91=9C=20=C2=B7=20=EC=A4=84=20=EB=A7=9E=EC=B6=A4?= =?UTF-8?q?=20=C2=B7=20=ED=99=95=EC=A0=95=20=C2=B7=20=EB=B9=A0=EC=A7=84=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B0=92=20=EB=B9=84=EA=B5=90)=20=C2=B7?= =?UTF-8?q?=20[=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80]=20=E3=80=8C?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4=20=ED=98=B8=ED=91=9C=E3=80=8D=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면 - 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음) - [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음 - Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로 - 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억) - 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Match.py | 17 + M02_MasterTemplete/M02_Labor_Router.py | 13 +- .../M02_MasterTemplete_Hopyo.css | 118 +++++ .../M02_MasterTemplete_Hopyo_Api.ts | 190 +++++++ .../M02_MasterTemplete_Hopyo_Pick.ts | 58 +++ .../M02_MasterTemplete_Hopyo_Side.ts | 128 +++++ .../M02_MasterTemplete_Hopyo_View.ts | 478 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Detail.ts | 8 +- .../M02_MasterTemplete_Labor_Pick.ts | 39 +- .../M02_MasterTemplete_UI_Side.ts | 13 +- resources/tester/helper_m02_hopyo_pick.cjs | 83 +++ resources/tester/test_m02_hopyo_match.py | 20 + resources/tester/test_m02_hopyo_screen.py | 132 +++++ 13 files changed, 1278 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts create mode 100644 resources/tester/helper_m02_hopyo_pick.cjs create mode 100644 resources/tester/test_m02_hopyo_screen.py diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index fc8136a1e..3d66494f5 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -445,6 +445,23 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} +def picks(folder: Path) -> list[dict[str, Any]]: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 실무자료 호표마다 맞춤 n/m + (이 층 기억 · 만드는 층).""" + memo, got, out = memories(folder), targets(), [] + for one in chain(folder): + layer, owner = refs.where(one) + for row in hopyo_store.rows(one): + doc = hopyo_store.read(one, row["키"]) + units = units_of(doc) + for hopyo in doc.get("호표") or []: + ready = match_hopyo(hopyo, memo, units, False, got)["맞춤"] + head = {"층": layer, "주인": owner, "키": row["키"], "파일": row["파일"]} + keep = ("번", "이름", "규격", "단위") + out.append({**head, **{k: hopyo.get(k) for k in keep}, "맞춤": ready}) + return out + + # ── 확정 저장 ───────────────────────────────────────────────────────── def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index ab7530fa1..1f028a4b1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -188,6 +188,16 @@ def hopyo_list( return {"줄": hopyo_store.rows(folder)} +@router.get("/layers/{layer}/hopyo-picks") +def hopyo_picks( + layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """[로직 추가] 「실무 호표」 줄 — 이 층 · 위 층 파일의 호표마다 + `{층, 주인, 키, 파일, 번, 이름, 규격, 단위, 맞춤}`(맞춤 n/m = 이 층 기억).""" + folder = _root(session, layer, owner) + return {"줄": _call(folder, hopyo_match.picks, folder)} + + @router.post("/layers/{layer}/hopyo") def hopyo_upload( layer: Layer, @@ -268,7 +278,8 @@ def hopyo_memory( class BuildBody(BaseModel): - """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음.""" + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · + `새로` = 있던 것을 덮음.""" 층: Layer 주인: str | None = None diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css new file mode 100644 index 000000000..236b453c9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -0,0 +1,118 @@ +/* M02 일위대가 실무자료(PLAN 324-4) — 좌측 판 · 본 화면(호표 표 · 줄 맞춤) · 색은 공용 토큰(밝음 · 어두움 같이) */ +.m02-hopyo-side { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-hopyo { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-hopyo__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-hopyo__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-hopyo__warn { + margin: 0; + color: var(--color-warning); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-hopyo__search { + max-width: 320px; +} + +.m02-hopyo__row { + cursor: pointer; +} + +.m02-hopyo__row:hover { + background: var(--color-mist-violet); +} + +.m02-hopyo__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m02-hopyo__line.is-out { + color: var(--color-text-muted); +} + +.m02-hopyo__state { + display: inline-block; + padding: 0 var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-hopyo__state.is-기억, +.m02-hopyo__state.is-같음, +.m02-hopyo__state.is-호표 { + border-color: var(--color-success); + color: var(--color-success); +} + +.m02-hopyo__state.is-후보 { + border-color: var(--color-warning); + color: var(--color-warning); +} + +.m02-hopyo__state.is-없음 { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.m02-hopyo__times { + width: 6em; +} + +.m02-hopyo__acts { + white-space: nowrap; +} + +.m02-hopyo__acts > button + button { + margin-left: var(--spacing-4); +} + +.m02-hopyo__block { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m02-hopyo__block h4 { + margin: 0; + font-size: var(--text-body-sm); +} + +.m02-hopyo__built { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.m02-hopyo__cands { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + margin-bottom: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts new file mode 100644 index 000000000..e11e7dcec --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -0,0 +1,190 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Api.ts + * 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · + * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; + +/** 층 · 주인(null = 자기 층) */ +export interface HopyoScope { + layer: Layer; + owner: string | null; +} + +export interface HopyoFile { + 키: string; + 파일: string; + 올린때: string; + 올린이: { id: number; 이름: string | null } | null; + 시트: string; + 호표수: number; + 어긋남: number; +} + +export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>; + +export interface HopyoBrief { + 번: number; + 이름: string; + 규격: string; + 단위: string; + 금액: Money; + /** [들어감 수, 줄 수] */ + 맞춤: [number, number]; +} + +export interface HopyoView extends HopyoFile { + 검산: { 어긋남: string[]; 총괄표: number }; + 호표: HopyoBrief[]; + 맞춤: [number, number]; +} + +/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ +export interface HopyoTarget { + 로직?: string; + 자재?: string; + 호표?: number; + 이름?: string; + 규격?: string; + 단위?: string | null; + 점수?: number; +} + +export interface HopyoMatch { + 열쇠: string; + 상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%"; + 대상: HopyoTarget | null; + 입력값: Record; + 못읽음: string[]; + 환산: { 배: number | null; 갈래: string } | null; + 들어감: boolean; + 후보?: HopyoTarget[]; +} + +export interface HopyoLine { + 차례: number; + 이름: string; + 규격: string; + 수량: number | null; + 단위: string; + 갈래: string; + 단가: Money; + 금액: Money; + 비고: string; + 맞춤: HopyoMatch; +} + +export interface HopyoOne extends HopyoFile { + 호표: HopyoBrief & { 줄: HopyoLine[] }; +} + +export interface HopyoGuess { + 대상: HopyoTarget; + 입력값: Record; + 못읽음: string[]; + 환산: { 배: number | null; 갈래: string }; +} + +export interface HopyoMissing { + 차례: number; + 이름: string; + 규격: string; + 수량: number | null; + 단위: string; + 금액: number | null; + 까닭: string; +} + +export interface HopyoBuilt { + 참조: LaborRef; + 이름: string; + 다시씀: boolean; + 빠진줄: HopyoMissing[]; + 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null }; +} + +/** [로직 추가] 「실무 호표」 줄 하나 */ +export interface HopyoPickRow extends Omit { + 층: Layer; + 주인: string | null; + 키: string; + 파일: string; +} + +/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */ +export type HopyoMemory = + | { 로직: string; 입력값: Record; 환산: number } + | { 자재: string; 입력값: Record; 환산: number } + | { 빼기: true } + | null; + +const enc = encodeURIComponent; +/** 지금 화면 층(구조물 조합 층) */ +export const pageScope = (): HopyoScope => ({ layer: getActiveLayer(), owner: getActiveOwner() }); +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 : ""}`; +}; + +export const listHopyo = async (scope: HopyoScope): Promise => + (await call<{ 줄: HopyoFile[] }>(at(scope, "/hopyo"))).줄; + +export const readHopyo = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo/${enc(key)}`)); + +export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => + call(at(scope, `/hopyo/${enc(key)}/${no}`)); + +export const guessHopyo = ( + scope: HopyoScope, + key: string, + no: number, + 차례: number, + 대상: HopyoTarget, +): Promise => + call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), { + method: "POST", + body: JSON.stringify({ 차례, 대상 }), + }); + +export const saveHopyoMemory = ( + scope: HopyoScope, + 줄: Record, +): Promise => + call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) }); + +export const deleteHopyo = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" }); + +/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */ +export const buildHopyo = ( + target: HopyoScope, + from: { 층: Layer; 주인: string | null; 키: string; 번: number }, + 새로 = false, +): Promise => + call(at(target, "/hopyo-build"), { + method: "POST", + body: JSON.stringify({ ...from, 새로 }), + }); + +export const listHopyoPicks = async (scope: HopyoScope): Promise => + (await call<{ 줄: HopyoPickRow[] }>(at(scope, "/hopyo-picks"))).줄; + +/** 엑셀 올리기 — 서버 까닭 글 그대로(시트 없음 · 20 MB · 엉터리) */ +export async function uploadHopyo(scope: HopyoScope, file: File): Promise { + const form = new FormData(); + form.append("file", file); + const response = await fetch(`${API_BASE_URL}/m02${at(scope, "/hopyo")}`, { + method: "POST", + credentials: "include", + body: form, + }); + const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & HopyoFile; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts new file mode 100644 index 000000000..04cdd4c89 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Pick.ts + * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · + * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 + * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음. + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; +import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +export const HOPYO_KIND = "hopyo"; +const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; +const ALIAS_MAX = 40; // 별칭 상한(44-6 · 서버 `ALIAS_MAX`) + +/** 서고 고르기에 「실무 호표」 덧줄 · 종류 고르기(단가산출 · 사전조합 · 실무 호표)를 붙임 */ +export function withHopyo(lib: LibraryPick): LibraryPick { + if (lib.layer === "project") return lib; + const scope = { layer: lib.layer, owner: lib.owner }; + return { + ...lib, + kindFilter: lib.kindFilter ?? [ + { value: "logic", text: tc("Kind_Logic") }, + { value: "precombo", text: tc("Kind_Combo") }, + { value: HOPYO_KIND, text: "실무 호표" }, + ], + more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)), + }; +} + +/** 실무 호표 한 줄 */ +export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { + return { + kind: HOPYO_KIND, + key: `${row.층}|${row.주인 ?? ""}|${row.키}|${row.번}`, + tag: "실무 호표", + name: [row.이름, row.규격].filter(Boolean).join(" "), + sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`, + unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`, + onPick: () => void pickHopyo(row, lib), + }; +} + +/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */ +export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { + try { + const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 }; + const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from); + const miss = got.빠진줄.length; + const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`; + if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000); + else showToast(head, "success"); + lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts new file mode 100644 index 000000000..74ce15703 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Side.ts + * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · + * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 + * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View"; + +export const HOPYO_TITLE = "일위대가 실무자료"; + +export interface HopyoSectionOptions { + /** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */ + canWrite: boolean; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface HopyoSection { + title: string; + body: HTMLElement; + /** 다른 화면이 열렸으면 고름 표시를 지움 */ + paint: () => void; + refresh: () => Promise; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { + const scope = pageScope(); + const list = el("div", { className: "m02-side__items ui-sidebar-list" }); + const input = el("input", { + attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" }, + }) as HTMLInputElement; + input.hidden = true; + const upload = createButton({ + label: "엑셀 올리기", + variant: "filled", + size: "sm", + disabled: !opt.canWrite, + onClick: () => input.click(), + }); + upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; + const body = el("div", { + className: "m02-hopyo-side", + children: [el("div", { className: "m02-side__row", children: [upload, input] }), list], + }); + let files: HopyoFile[] = []; + let open: { key: string; view: HTMLElement } | null = null; + let seq = 0; + + input.addEventListener("change", () => { + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + upload.disabled = true; + uploadHopyo(scope, file) + .then(async (got) => { + const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : ""; + showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); + await refresh(); + await show(got); + }) + .catch((error) => showToast(why(error), "error")) + .finally(() => (upload.disabled = !opt.canWrite)); + }); + + function paintList(): void { + list.replaceChildren( + ...(files.length + ? files.map((f) => { + const row = el("button", { + className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`, + text: `${f.파일} · 호표 ${f.호표수}`, + attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 }, + }); + row.addEventListener("click", () => void show(f)); + return row; + }) + : [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]), + ); + } + + async function show(file: HopyoFile): Promise { + if (open?.key === file.키 && open.view.isConnected) return; + if (!(await opt.confirmLeave())) return; + const view = buildHopyoView(file.키, { + scope, + canWrite: opt.canWrite, + onDeleted: () => { + view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" })); + void refresh(); + }, + }); + opt.show(`${HOPYO_TITLE} — ${file.파일}`, view); + opt.onOpen(); + open = { key: file.키, view }; + paintList(); + } + + async function refresh(): Promise { + const mine = ++seq; + try { + const got = await listHopyo(scope); + if (mine === seq) files = got; + } catch (error) { + if (mine === seq) files = []; + showToast(why(error), "error"); + } + if (mine === seq) paintList(); + } + + const paint = (): void => { + if (open && !open.view.isConnected) { + open = null; + paintList(); + } + }; + + void refresh(); + return { title: HOPYO_TITLE, body, paint, refresh }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts new file mode 100644 index 000000000..4318fa45a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -0,0 +1,478 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_View.ts + * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · + * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · + * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. + * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { openModal } from "@ui/ui_template_modal"; +import { + buildHopyo, + deleteHopyo, + guessHopyo, + readHopyo, + readHopyoOne, + saveHopyoMemory, + type HopyoBuilt, + type HopyoGuess, + type HopyoLine, + type HopyoMemory, + type HopyoOne, + type HopyoScope, + type HopyoTarget, + type HopyoView, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { pickMaterial } from "./M02_Material_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +export interface HopyoViewOptions { + scope: HopyoScope; + /** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */ + canWrite: boolean; + /** 파일을 지웠음 — 좌측 목록 다시 */ + onDeleted: () => void; +} + +const STATE: Record = { + 기억: "기억", + 같음: "같음", + 후보: "후보", + 없음: "없음", + 빼기: "뺌", + 호표: "호표", + "%": "% 줄", +}; +const MATERIAL = new Set(["자재", "별도계상"]); + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); + +const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(), + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + body, + ], + }), + ], + }); +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + grid(heads, el("tbody", { children: rows })); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ +export const targetText = (t: HopyoTarget | null | undefined): string => { + if (!t) return ""; + if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); + const spec = t.규격 ? ` ${t.규격}` : ""; + return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; +}; + +/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */ +export const inputsText = (values: Record, unread: string[]): string => + [ + Object.entries(values) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", "), + unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "", + ] + .filter(Boolean) + .join(" · "); + +/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ +export function memoryOf( + target: HopyoTarget | null, + values: Record, + times: number | null, +): HopyoMemory { + if (!target || times === null || !(times > 0)) return null; + if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times }; + if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times }; + return null; +} + +export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { + const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); + let nav = 0; // 늦게 온 응답 버림 + let query = ""; + + async function load(get: () => Promise, retry: () => void): Promise { + const my = ++nav; + const stop = showLoading(view); + try { + const got = await get(); + return my === nav ? got : null; + } catch (error) { + if (my === nav) view.replaceChildren(loadingError(why(error), retry)); + return null; + } finally { + stop(); + } + } + + /* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */ + async function drawFile(): Promise { + const file = await load( + () => readHopyo(opt.scope, key), + () => void drawFile(), + ); + if (!file) return; + const search = el("input", { + className: "ui-input ui-input--sm m02-hopyo__search", + attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query }, + }) as HTMLInputElement; + const body = el("tbody"); + const paint = (): void => { + const q = search.value.trim().toLowerCase(); + query = search.value; + body.replaceChildren( + ...file.호표 + .filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q)) + .map((h) => { + const tr = el("tr", { + className: "m02-hopyo__row", + attrs: { "data-hopyo-no": String(h.번) }, + children: [ + td(`제${h.번}호표`), + td(h.이름), + td(h.규격), + td(h.단위), + td(won(h.금액.계), "m02-hopyo__num"), + td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"), + ], + }); + tr.addEventListener("click", () => void drawOne(h.번)); + return tr; + }), + ); + }; + search.addEventListener("input", paint); + paint(); + const wrong = file.검산.어긋남; + view.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + el("strong", { text: file.파일 }), + note(`시트 ${file.시트}`), + note(`호표 ${file.호표수}`), + note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), + note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + ...(opt.canWrite ? [dropButton(file)] : []), + ], + }), + ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), + search, + grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body), + ); + } + + const dropButton = (file: HopyoView): HTMLButtonElement => + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: async () => { + const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`; + if (!(await showConfirmDialog(ask, "지우기"))) return; + try { + await deleteHopyo(opt.scope, key); + showToast("지움", "success"); + opt.onDeleted(); + } catch (error) { + fail(error); + } + }, + }); + + /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ + async function drawOne(no: number, built?: HopyoBuilt): Promise { + const got = await load( + () => readHopyoOne(opt.scope, key, no), + () => void drawOne(no), + ); + if (!got) return; + const h = got.호표; + const picked = new Map(); // 고른 대상(확정 전) + const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawFile()); + const rows = el("tbody"); + const paintRows = (): void => { + rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows))); + }; + paintRows(); + const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null); + const tools: HTMLElement[] = []; + if (opt.canWrite) { + const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); + all.disabled = !same.length; + all.addEventListener("click", () => void confirmAll(no, same)); + const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); + make.addEventListener("click", () => void build(no, false)); + tools.push(all, make); + } + const missing = h.줄.filter((x) => !x.맞춤.들어감); + view.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + back, + el("strong", { text: `제${h.번}호표 ${h.이름}` }), + note(h.규격), + note(h.단위), + note(`엑셀 단가 ${won(h.금액.계)}`), + note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), + ...tools, + ], + }), + h.줄.length + ? grid( + [ + "갈래", + "품명", + "규격", + "수량", + "단위", + "엑셀 단가", + "맞춤", + "대상", + "입력값", + "환산", + "", + ], + rows, + "m02-hopyo__lines", + ) + : el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }), + ...(missing.length ? [missingBlock(missing)] : []), + ...(built ? [builtBlock(no, built)] : []), + ); + } + + function lineRow( + got: HopyoOne, + line: HopyoLine, + picked: Map, + repaint: () => void, + ): HTMLElement { + const m = line.맞춤; + const mine = picked.get(line.차례); + const target = mine?.대상 ?? m.대상; + const values = mine?.입력값 ?? m.입력값; + const conv = mine?.환산 ?? m.환산; + const fixed = ["%", "호표", "빼기"].includes(m.상태); + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { + type: "number", + step: "any", + min: "0", + value: conv?.배 != null ? String(conv.배) : "", + }, + }) as HTMLInputElement; + times.disabled = !opt.canWrite || fixed || !target; + const state = mine ? "고름" : (STATE[m.상태] ?? m.상태); + const actions: HTMLElement[] = []; + if (opt.canWrite && m.상태 === "빼기") { + actions.push( + createButton({ + label: "되살림", + variant: "ghost", + size: "sm", + onClick: () => void remember(got.호표.번, { [m.열쇠]: null }), + }), + ); + } else if (opt.canWrite && !["%", "호표"].includes(m.상태)) { + actions.push( + createButton({ + label: "고르기", + variant: "ghost", + size: "sm", + onClick: () => + choose(line, (t) => + guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( + (g) => (picked.set(line.차례, g), repaint()), + fail, + ), + ), + }), + createButton({ + label: "확정", + size: "sm", + disabled: !target, + onClick: () => { + const body = memoryOf(target, values, times.value === "" ? null : Number(times.value)); + if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error"); + void remember(got.호표.번, { [m.열쇠]: body }); + }, + }), + createButton({ + label: "빼기", + variant: "ghost", + size: "sm", + onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }), + }), + ); + } + return el("tr", { + className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": m.상태 }, + children: [ + td(line.갈래), + td(line.이름), + td(line.규격), + td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), + td(line.단위), + td(won(line.단가.계), "m02-hopyo__num"), + el("td", { + children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })], + }), + td(targetText(target)), + td(inputsText(values, mine?.못읽음 ?? m.못읽음)), + el("td", { + children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")], + }), + el("td", { className: "m02-hopyo__acts", children: actions }), + ], + }); + } + + /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ + function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { + const material = MATERIAL.has(line.갈래); + openModal({ + title: `고르기 — ${line.이름} ${line.규격}`.trim(), + closeLabel: "닫기", + dialogClass: "m02-modal m02-hopyo__choose", + mount: (body, close) => { + const pick = (t: HopyoTarget): void => { + close(); + done(t); + }; + const list = (line.맞춤.후보 ?? []).map((c) => + createButton({ + label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + variant: "ghost", + size: "sm", + onClick: () => pick(c), + }), + ); + const more = createButton({ + label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + size: "sm", + onClick: () => { + close(); + if (material) pickMaterial((x) => done({ 자재: x.코드 })); + else openLogicPicker((x) => done({ 로직: x.키 })); + }, + }); + body.append( + el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "후보 없음" }), + el("div", { className: "m02-hopyo__cands", children: list }), + more, + ); + }, + }); + } + + async function remember(no: number, 줄: Record): Promise { + try { + await saveHopyoMemory(opt.scope, 줄); + await drawOne(no); + } catch (error) { + fail(error); + } + } + + async function confirmAll(no: number, lines: HopyoLine[]): Promise { + const body: Record = {}; + for (const x of lines) { + const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null); + if (one) body[x.맞춤.열쇠] = one; + } + await remember(no, body); + } + + async function build(no: number, fresh: boolean): Promise { + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; + const got = await buildHopyo(opt.scope, from, fresh); + showToast( + got.빠진줄.length ? `만듦 — 빠진 줄 ${got.빠진줄.length}` : "만듦", + got.빠진줄.length ? "warning" : "success", + ); + await drawOne(no, got); + } catch (error) { + fail(error); + } + } + + const missingBlock = (lines: HopyoLine[]): HTMLElement => + el("div", { + className: "m02-hopyo__block", + children: [ + el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }), + table( + ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"], + lines.map((x) => + el("tr", { + children: [ + td(x.이름), + td(x.규격), + td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), + td(x.단위), + td(won(x.금액.계), "m02-hopyo__num"), + td(STATE[x.맞춤.상태] ?? x.맞춤.상태), + ], + }), + ), + ), + ], + }); + + function builtBlock(no: number, got: HopyoBuilt): HTMLElement { + const c = got.비교; + const again = got.다시씀 + ? [ + note(`있던 사전조합 ${got.참조.키} 를 다시 씀`), + createButton({ + label: "새로 만들기", + variant: "danger", + size: "sm", + onClick: async () => { + const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`; + if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true); + }, + }), + ] + : [note(`새 사전조합 ${got.참조.키}`)]; + return el("div", { + className: "m02-hopyo__block m02-hopyo__built", + attrs: { "data-built": got.참조.키 }, + children: [ + el("div", { className: "m02-hopyo__bar", children: again }), + note( + `값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, + ), + ], + }); + } + + void drawFile(); + return view; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index f0b65b1a6..7664d8972 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -59,7 +59,7 @@ import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; @@ -484,12 +484,12 @@ export function mountLaborDetail( : opt.layer === getActiveLayer() ? getActiveOwner() : null, - pick: (ref: LaborRef, name: string) => { + pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) - cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 }); changed(); }, }; @@ -507,7 +507,7 @@ export function mountLaborDetail( void loadLogics().then(() => changed()); }, // 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422) - library, + withHopyo(library), ), }); const addMat = addMaterialButton( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 275b0520a..1ac24376e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -4,7 +4,8 @@ * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · * 프로젝트 · 카드엔 서고 줄 없음 · 거름 = 로직 품셈 분류 + 사전조합 새 분류(149-2 · 구조물군 › 공통 + 종류) * 단가연결(251 · B08 거리이정표 · B09 설계내역서) = 종류 고르기(단가산출 · 사전조합 · 덧붙인 줄 종류) + - * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` + * 덧붙일 줄(구조물 조합 · B09 호표) — `lib.extra` · `lib.kindFilter` · 늦게 받는 덧줄 `lib.more` + * (324-4 실무 호표 · `withHopyo`) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -28,28 +29,35 @@ import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; +export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; + const opts = (values: string[]): { value: string; text: string }[] => [L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v })); type Scope = Omit; +/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */ +export interface PickExtra { + kind: string; + key: string; + tag: string; + name: string; + sub: string; + unit: string; + onPick: () => void; +} /** 서고 탭 — 일위대가가 있는 층 · 주인(null = 자기 층) · 고르면 참조 */ export interface LibraryPick { layer: Layer; owner: string | null; - pick: (ref: LaborRef, name: string) => void; + /** `alias` = 참조 줄 별칭(324-4 실무 호표 품명 · 없으면 빈칸) */ + pick: (ref: LaborRef, name: string, alias?: string | null) => void; /** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */ libOnly?: boolean; title?: string; /** 목록 끝 덧붙일 줄(251 · 구조물 조합 · B09 호표) — kind = 종류 고르기 값 */ - extra?: Array<{ - kind: string; - key: string; - tag: string; - name: string; - sub: string; - unit: string; - onPick: () => void; - }>; + extra?: PickExtra[]; + /** 창을 연 뒤 받는 덧줄(324-4 실무 호표) — 받으면 `extra` 뒤에 붙음 */ + more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; } @@ -97,6 +105,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const kindOk = (k: string): boolean => !kindSel?.select.value || kindSel.select.value === k; let loading = false; let seq = 0; + let later: PickExtra[] = []; // lib.more 로 받은 덧줄 /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ const finder = createFindList({ placeholder: tc("Pick_Search"), @@ -120,13 +129,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (!detail.select.value || x.row.상세구분 === detail.select.value) && (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), ); - const extra = (lib?.extra ?? []).filter( + const extra = [...(lib?.extra ?? []), ...later].filter( (x) => kindOk(x.kind) && !sub.select.value && (!q || `${x.tag} ${x.name} ${x.sub}`.toLowerCase().includes(q)), ); - const total = all.length + libRows.length + (lib?.extra?.length ?? 0); + const total = all.length + libRows.length + (lib?.extra?.length ?? 0) + later.length; const shown = found.length + libFound.length + extra.length; finder.hint.textContent = loading ? tc("Loading") @@ -271,6 +280,10 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); finder.search.focus(); + void lib?.more?.().then( + (got) => ((later = got), find()), + (error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), + ); if (!lib?.libOnly) void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f0e73ad40..c6d835e7a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 사전조합」(층 단추 따로 · 2단 폴더 나무 · - * `UI_Side_Precombo` · 50-3 · 134-3) · 「도면 양식」(새로 · 본떠 · 지우기). + * `UI_Side_Precombo` · 50-3 · 134-3) · 「일위대가 실무자료」(구조물 조합 층 · `Hopyo_Side` · 324-4) · + * 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -31,6 +32,7 @@ import { import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildOptionsSection } from "./StructOptions/M02_SO_Options"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; +import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildPrecomboSection, @@ -165,11 +167,19 @@ export function buildSide(opt: SideOptions): SideHandle { onOpen: () => (options.clear(), paint()), }) : null; + // 일위대가 실무자료(324-4) — 사전조합 아래 · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 + const hopyo = buildHopyoSection({ + canWrite: canEdit(layer, opt.isAdmin), + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (options.clear(), practice?.clear(), paint()), + }); const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ summary, precomboSec, + section(hopyo.title, hopyo.body), ...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []), ...(practice ? [section(practice.title, practice.body)] : []), ], @@ -225,6 +235,7 @@ export function buildSide(opt: SideOptions): SideHandle { function paint(): void { paintList(); precombo.paint(); + hopyo.paint(); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs new file mode 100644 index 000000000..0ab06e6e8 --- /dev/null +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -0,0 +1,83 @@ +/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · + * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · + * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) +const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ + url, + method: init.method ?? "GET", + body: init.body ? JSON.parse(init.body) : null, + }); + const body = url.includes("/hopyo-picks") ? { 줄: input.rows } : input.built; + return { status: 200, ok: true, json: async () => body }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const picked = []; + const lib = { + layer: "company", + owner: "7", + pick: (ref, name, alias) => picked.push({ ref, name, alias }), + }; + const wrapped = pick.withHopyo(lib); + const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r); + await pick.pickHopyo(input.rows[0], lib); + const project = pick.withHopyo({ ...lib, layer: "project" }); + const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const out = { + calls, + kinds: wrapped.kindFilter.map((k) => k.value), + rows, + picked, + project: project.more === undefined && project.kindFilter === undefined, + view: { + target: [ + view.targetText({ 로직: "GF000101", 이름: "구조물터파기", 단위: "㎥" }), + view.targetText({ 자재: "MT1", 이름: "레미콘", 규격: "25-21-120", 단위: "㎥" }), + view.targetText({ 호표: 46, 단위: "㎡" }), + ], + inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]), + memory: [ + view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1), + view.memoryOf({ 자재: "MT1" }, {}, 0.5), + view.memoryOf({ 로직: "GF000101" }, {}, 0), + view.memoryOf(null, {}, 1), + ], + }, + }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 2f1179e0c..38f3039b7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -165,3 +165,23 @@ def test_확정_검사와_위_층_기억(client): assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200 assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저 assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7) + + +def test_로직_추가_고르기_줄은_이_층과_위_층(client): + client.who.update(role="SYSTEM_ADMIN") + assert _post(client, "system").status_code == 200 + client.who.update(role="USER") + assert _post(client).status_code == 200 + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + assert [r["층"] for r in rows].count("personal") == 68 + assert [r["층"] for r in rows].count("system") == 68 + three = next(r for r in rows if r["층"] == "system" and r["번"] == 3) + assert {k: three[k] for k in ("주인", "키", "이름", "단위")} == { + "주인": None, + "키": "HP-000001", + "이름": "임도표지석", + "단위": "개소", + } + assert three["맞춤"][1] == 4 + system = client.get("/api/m02/layers/system/hopyo-picks").json()["줄"] + assert {r["층"] for r in system} == {"system"} # 마스터 = 마스터만 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py new file mode 100644 index 000000000..92fb610d9 --- /dev/null +++ b/resources/tester/test_m02_hopyo_screen.py @@ -0,0 +1,132 @@ +"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄. + +- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · + 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · + 본 화면 글 도우미 +- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · + 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" +NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View") + + +def _text(name: str) -> str: + return (M02 / name).read_text(encoding="utf-8") + + +def _run() -> dict: + row = { + "층": "system", + "주인": None, + "키": "HP-000001", + "파일": "북부청.xls", + "번": 3, + "이름": "임도표지석", + "규격": "910×600×150", + "단위": "개소", + "맞춤": [2, 4], + } + built = { + "참조": {"층": "company", "주인": "7", "키": "SC-000009"}, + "이름": "임도표지석", + "다시씀": False, + "빠진줄": [{"차례": 0}], + "비교": {}, + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")], + input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_고르기_창_실무_호표_줄과_고름(): + got = _run() + assert got["calls"][0] == { + "url": "/api/m02/layers/company/hopyo-picks?owner=7", + "method": "GET", + "body": None, + } + assert got["kinds"] == ["logic", "precombo", "hopyo"] + assert got["rows"] == [ + { + "kind": "hopyo", + "key": "system||HP-000001|3", + "tag": "실무 호표", + "name": "임도표지석 910×600×150", + "sub": "북부청.xls · 제3호표 · 마스터", + "unit": "개소 · 맞춤 2/4", + } + ] + build = got["calls"][1] + assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 + assert build["body"] == { + "층": "system", + "주인": None, + "키": "HP-000001", + "번": 3, + "새로": False, + } + assert got["picked"] == [ + { + "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, + "name": "임도표지석", + "alias": "임도표지석", + } + ] + assert got["project"] is True # 프로젝트 층 = 덧줄 없음 + + +def test_본_화면_글_도우미(): + view = _run()["view"] + assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"] + assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업" + assert view["memory"] == [ + {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, + {"자재": "MT1", "입력값": {}, "환산": 0.5}, + None, # 환산 0 = 확정 못 함 + None, # 대상 없음 + ] + + +def test_좌측_판_자리와_권한(): + side = _text("M02_MasterTemplete_UI_Side.ts") + assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side + host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 + assert "hopyo.paint();" in side.split("function paint(): void {")[1] + names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side)) + assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만 + assert "LaborSection" not in side and "labor?." not in side and "picked" not in side + hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side + assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side + + +def test_로직_추가_한_곳과_파일_규칙(): + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침) + assert "withHopyo(library)," in detail + assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail + pick = _text("M02_MasterTemplete_Labor_Pick.ts") + assert "more?: () => Promise;" in pick and "...later" in pick + for name in NEW: + text = _text(f"M02_MasterTemplete_{name}.ts") + assert len(text.splitlines()) <= 700, name + css = _text("M02_MasterTemplete_Hopyo.css") + assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) + assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")