From 1e1fb9f811d30a2a2eb2429517d105d3198853b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 20:46:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(M02):=20=EC=98=9B=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=ED=8C=A8=EB=84=90=20=EC=A7=80=EC=9B=80=20=E2=80=94?= =?UTF-8?q?=20ComboPanel=20=C2=B7=20Combo(=EC=A1=B0=ED=95=A9=20=EC=A4=84?= =?UTF-8?q?=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B0=88=EB=9D=BC=20=EB=82=B4=EA=B8=B0=20=EC=B0=BD)?= =?UTF-8?q?=20=C2=B7=20Combo=5FApi=20=C2=B7=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20branchCombo=20=C2=B7=20setColumnCombo=20?= =?UTF-8?q?=C2=B7=20=EC=93=B0=EB=8A=94=20=EA=B3=B3=200=20(PLAN=2039-4=20D3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 2 +- .../M02_MasterTemplete_Api_Fetch.ts | 27 -- .../M02_MasterTemplete_Combo.css | 75 ----- .../M02_MasterTemplete_Combo.ts | 309 ------------------ .../M02_MasterTemplete_ComboPanel.css | 91 ------ .../M02_MasterTemplete_ComboPanel.ts | 227 ------------- .../M02_MasterTemplete_Combo_Api.ts | 35 -- resources/tester/test_m02_structure_panel.py | 14 +- 8 files changed, 10 insertions(+), 770 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo.css delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 9f9e3ae48..3e3f493ea 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -59,7 +59,7 @@ export const STATE_REGISTRY = { "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, /** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */ "drainage-visible": { bucket: "pref", scope: "global" }, - /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ + /** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 63d2a3671..4c34f3e76 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -187,33 +187,6 @@ export interface StructureCombos { export const fetchStructureCombos = (): Promise => scoped("/structure-combos"); /** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */ -export interface BranchedCombo { - 키: string; - 이름: string; - 새로만듦: boolean; - 집계표판: string; -} - -/** 이 열이 가리키는 일위대가 조합을 고친 담은 로직으로 갈라 냄 — 새 UA · 집계표 그 열만 새 키 · - * `판`(집계표 판)을 주면 어긋날 때 StaleError · 원래 조합은 그대로 */ -export const branchCombo = ( - column: string, - body: { - 본뜬키: string; - 담은로직: { 로직: string; 메모?: string | null }[]; - 이름: string; - 판?: string; - }, -): Promise => - scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) }); - -/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로 · `새로만듦` false) */ -export const setColumnCombo = ( - column: string, - body: { 종류: "일위대가"; 키: string; 판?: string }, -): Promise => - scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) }); - /* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */ export interface OptionKind { type_id: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.css b/M02_MasterTemplete/M02_MasterTemplete_Combo.css deleted file mode 100644 index e51b4b0db..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.css +++ /dev/null @@ -1,75 +0,0 @@ -/* M02 조합 줄(면적산출) · 조합 고치기 창 · 자재 목록 옆 판(재료산출) */ - -/* 알약 막대 위 한 줄 */ -.m02-combo { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4) var(--spacing-12); - font-size: var(--text-body-sm); -} - -.m02-combo__key { - padding: 0 var(--spacing-8); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--color-accent) 14%, transparent); - color: var(--color-text); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.m02-combo__name { - color: var(--color-text); - overflow-wrap: anywhere; -} - -.m02-combo__meta, -.m02-combo__origin { - color: var(--color-text-secondary); - white-space: nowrap; -} - -.m02-combo__origin { - font-size: var(--text-caption); -} - -.m02-combo > .ui-btn { - margin-left: auto; -} - -/* 조합 고치기 창 — 담은 로직 표(M01 조합 화면과 같은 줄) */ -.ui-modal.m02-combo-edit { - width: min(980px, 94vw); -} - -.m02-combo-edit__row { - display: grid; - grid-template-columns: 3rem 7rem minmax(10rem, 1.4fr) 6rem minmax(8rem, 1fr) auto; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-4) 0; - border-bottom: 1px solid var(--color-border); -} - -.m02-combo-edit__row--head { - color: var(--color-text-secondary); - font-size: var(--text-caption); - font-weight: 600; -} - -.m02-combo-edit__acts { - display: flex; - gap: var(--spacing-4); -} - -.m02-combo-edit__foot, -.m02-branch__foot { - display: flex; - gap: var(--spacing-8); - justify-content: flex-end; -} - -.m02-branch__msg { - margin: 0; - line-height: 1.5; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts deleted file mode 100644 index 02b0cc559..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ /dev/null @@ -1,309 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Combo.ts - * 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만). - * 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어 - * 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양. - * ========================================================================== */ - -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { fetchCombo, type ComboLine } from "./M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch"; -import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; -import "./M02_MasterTemplete_Combo.css"; - -const TEXT = { - combo: "일위대가", - logics: "담은 로직 {n}", - shared: "다른 열 {n} 이 같이 씀", - alone: "이 열만 씀", - branched: "본뜬 조합 {value}", - edit: "조합 고치기", - editTitle: "일위대가 조합 고치기 — {value}", - no: "차례", - key: "키", - name: "이름", - unit: "결과 단위", - memo: "메모", - up: "위", - down: "아래", - remove: "빼기", - add: "로직 추가", - apply: "적용", - close: "닫기", - empty: "담은 로직이 없음", - unchanged: "고친 것이 없음", - discard: "고친 것을 버리고 닫음", - discardOk: "버리고 닫음", - nameEmpty: "이름을 넣음", - noLines: "담은 로직이 없어 적용 안 됨 — 로직을 하나 이상 담음", - nameLong: "이름은 {n}자까지", - loadFailed: "조합을 못 읽음 — {value}", - askTitle: "새 조합으로 갈라 냄", - askName: "새 조합 이름", - askOk: "새로 만듦", - askCancel: "취소", - askBody: - "이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로", - askAlone: - "이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로", - made: "새 {kind} {key} 「{name}」 을 만듦 — 이 열이 가리킴", - reused: "같은 내용의 {kind} {key} 「{name}」 을 씀 — 새로 안 만듦", - failed: "조합을 못 고침 — {value}", -}; - -/** 조합 이름 글자 수 한도 — 서버 `master_combo.MAX_이름` 과 같음 */ -const MAX_NAME = 60; - -const fill = (text: string, value: string | number): string => - text.replace("{n}", String(value)).replace("{value}", String(value)); - -/** 갈라 내기 확인 모달 — 이름 칸을 미리 채워 두고 고칠 수 있음 · 취소면 null */ -export function askBranch(message: string, defaultName: string): Promise { - return new Promise((resolve) => { - let done = false; - const finish = (value: string | null): void => { - if (!done) resolve(value); - done = true; - }; - openModal({ - title: TEXT.askTitle, - closeLabel: TEXT.askCancel, - dialogClass: "m02-modal m02-branch", - mount: (body, close) => { - const name = createInputField({ label: TEXT.askName, type: "text" }); - name.input.value = defaultName.slice(0, MAX_NAME); - name.input.maxLength = MAX_NAME; - const ok = createButton({ - label: TEXT.askOk, - variant: "filled", - onClick: () => { - const value = name.input.value.trim(); - if (!value || value.length > MAX_NAME) { - name.setError(value ? fill(TEXT.nameLong, MAX_NAME) : TEXT.nameEmpty); - return name.input.focus(); - } - finish(value); - close(); - }, - }); - name.input.addEventListener("input", () => name.setError(undefined)); - name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok.click()); - body.append( - el("p", { className: "m02-branch__msg", text: message }), - name.root, - el("div", { className: "m02-branch__foot", children: [ok] }), - ); - name.input.focus(); - }, - }); - // 닫기 단추 · Esc · 바깥 누르기 — 창이 빠지면 취소 - const watch = new MutationObserver(() => { - if (document.querySelector(".m02-branch")) return; - watch.disconnect(); - finish(null); - }); - watch.observe(document.body, { childList: true }); - }); -} - -/** 새 조합 이름 자동 — 원래 이름(끝 「 (n)」 뗌 · 서버가 새로 붙임) · 구조물 이름(한쪽이 다른 쪽을 - * 이미 품으면 긴 쪽 하나만) */ -export const branchName = ( - named: string, - label: string, - original = named.replace(/ \(\d+\)$/, ""), -): string => - !original || label.includes(original) - ? label || original - : !label || original.includes(label) - ? original - : `${original} · ${label}`; - -/** 결과 알림 — 새로 만듦 / 같은 내용의 있는 조합을 씀(`kind` = 일위대가 조합 · 자재 조합) */ -export const branchToast = (r: BranchedCombo, kind = "일위대가 조합"): void => - showToast( - (r.새로만듦 ? TEXT.made : TEXT.reused) - .replace("{kind}", kind) - .replace("{key}", r.키) - .replace("{name}", r.이름), - "success", - ); - -export interface ComboBarOptions { - /** 집계표 열 id */ - column: string; - /** 화면 제목에 쓰는 구조물 이름(집계표 머리 층을 이은 글) — 새 이름 자동 미리 채움에 씀 */ - label: string; - isAdmin: boolean; - /** 고치기 전 — 저장 안 한 시트 고침이 있으면 물음 · false 면 그만둠 */ - beforeEdit: () => Promise; - /** 갈라 낸 뒤 — 알약 다시 받기 */ - onApplied: () => Promise | void; -} - -interface Line { - 로직: string; - 메모: string | null; - 이름: string; - 결과단위: string; -} - -/** 조합 줄 — 이 열에 일위대가가 없으면 null */ -export async function buildComboBar(opts: ComboBarOptions): Promise { - const base = await readColumnBase(opts.column); - if (!base.일위대가) return null; - const key = base.일위대가; - let one; - try { - one = await fetchCombo(key); - } catch (error) { - showToast(fill(TEXT.loadFailed, reasonOf(error)), "error"); - return null; - } - const combo = one.combo as typeof one.combo & { 본뜬키?: string | null }; - const same = base.다른열일위대가; - const parts = [ - el("span", { className: "m02-combo__key", text: `${TEXT.combo} ${key}` }), - el("span", { className: "m02-combo__name", text: combo.이름 }), - el("span", { className: "m02-combo__meta", text: fill(TEXT.logics, combo.담은로직.length) }), - el("span", { - className: "m02-combo__meta", - text: same ? fill(TEXT.shared, same) : TEXT.alone, - }), - ]; - if (opts.isAdmin && combo.본뜬키) { - parts.push( - el("span", { className: "m02-combo__origin", text: fill(TEXT.branched, combo.본뜬키) }), - ); - } - const bar = el("div", { className: "m02-combo", children: parts }); - if (!opts.isAdmin) return bar; - - const edit = createButton({ label: TEXT.edit, variant: "ghost", size: "sm" }); - edit.addEventListener("click", async () => { - if (!(await opts.beforeEdit())) return; - openEditor(one.줄, combo.이름, same); - }); - bar.append(edit); - return bar; - - function openEditor(from: ComboLine[], comboName: string, others: number): void { - const original = JSON.stringify(from.map((l) => [l.로직, l.메모 ?? null])); - const changed = (): boolean => - JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) !== original; - const lines: Line[] = from.map((l) => ({ - 로직: l.로직, - 메모: l.메모, - 이름: l.이름 ?? "", - 결과단위: l.결과단위 ?? "", - })); - openModal({ - title: fill(TEXT.editTitle, opts.label), - closeLabel: TEXT.close, - dialogClass: "m02-modal m02-combo-edit", - // 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음 - beforeClose: () => !changed() || showConfirmDialog(TEXT.discard, TEXT.discardOk), - mount: (body, close) => { - const table = el("div", { className: "m02-combo-edit__table" }); - const paint = (): void => { - const head = el("div", { - className: "m02-combo-edit__row m02-combo-edit__row--head", - children: [TEXT.no, TEXT.key, TEXT.name, TEXT.unit, TEXT.memo, ""].map((t) => - el("span", { text: t }), - ), - }); - const rows = lines.map((line, i) => { - const memo = el("input", { - className: "ui-input", - attrs: { type: "text", value: line.메모 ?? "", title: line.메모 ?? "" }, - }); - memo.addEventListener("input", () => { - line.메모 = memo.value || null; - memo.title = memo.value; - }); - const move = (to: number): void => { - if (to < 0 || to >= lines.length) return; - [lines[i], lines[to]] = [lines[to], lines[i]]; - paint(); - }; - const up = createButton({ label: "▲", variant: "ghost", size: "sm" }); - up.title = TEXT.up; - up.disabled = i === 0; - up.addEventListener("click", () => move(i - 1)); - const down = createButton({ label: "▼", variant: "ghost", size: "sm" }); - down.title = TEXT.down; - down.disabled = i === lines.length - 1; - down.addEventListener("click", () => move(i + 1)); - const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" }); - del.addEventListener("click", () => { - lines.splice(i, 1); - paint(); - }); - return el("div", { - className: "m02-combo-edit__row", - attrs: { "data-combo-line": line.로직 }, - children: [ - el("span", { text: String(i + 1) }), - el("span", { text: line.로직 }), - el("span", { text: line.이름 }), - el("span", { text: line.결과단위 }), - memo, - el("span", { className: "m02-combo-edit__acts", children: [up, down, del] }), - ], - }); - }); - table.replaceChildren( - head, - ...(rows.length - ? rows - : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), - ); - }; - paint(); - const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" }); - add.addEventListener("click", () => - openLogicPicker((logic) => { - lines.push({ 로직: logic.키, 메모: null, 이름: logic.이름, 결과단위: logic.결과단위 }); - paint(); - }), - ); - const apply = createButton({ label: TEXT.apply, variant: "filled" }); - apply.addEventListener("click", async () => { - if (!changed()) { - return showToast(TEXT.unchanged, "info"); - } - if (!lines.length) return showToast(TEXT.noLines, "error"); - const message = others ? fill(TEXT.askBody, others + 1) : TEXT.askAlone; - const name = await askBranch(message, branchName(comboName, opts.label)); - if (name === null) return; - apply.disabled = true; - try { - const result = await branchCombo(opts.column, { - 본뜬키: key, - 담은로직: lines.map((l) => ({ 로직: l.로직, 메모: l.메모 })), - 이름: name, - }); - branchToast(result); - close(); - await opts.onApplied(); - } catch (error) { - apply.disabled = false; - showToast(fill(TEXT.failed, reasonOf(error)), "error"); - } - }); - body.append( - table, - el("div", { className: "m02-combo-edit__foot", children: [add, apply] }), - ); - }, - }); - } -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css deleted file mode 100644 index 6b2071651..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.css +++ /dev/null @@ -1,91 +0,0 @@ -/* M02 면적산출 · 재료산출 오른쪽 조합 패널 — 틀은 공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 */ - -/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-cpanel-width` 로 줌 */ -.m02-cpanel { - position: relative; - display: flex; - flex: 0 0 auto; - flex-direction: column; - width: var(--m02-cpanel-width, 24rem); - min-width: 280px; - max-width: 60%; - min-height: 0; - border-left: 1px solid var(--color-border); -} - -.m02-cpanel.is-resizing { - transition: none; -} - -.m02-cpanel.is-collapsed { - width: 0; - min-width: 0; -} - -.m02-cpanel.is-collapsed > .m02-cpanel__body, -.m02-cpanel.is-collapsed > .ui-resizer { - display: none; -} - -/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */ -.m02-cpanel > .ui-workflow-overlay__handle--side { - z-index: 6; - right: auto; - left: calc(-1 * var(--spacing-24)); - border-right: 0; - border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); -} - -/* 안쪽만 굴림 — 페이지 스크롤 안 생김 */ -.m02-cpanel__body { - flex: 1 1 0; - min-height: 0; - overflow: auto; - padding: var(--spacing-8); -} - -.m02-cpanel__list { - max-height: 24rem; - overflow: auto; -} - -.m02-cpanel__row { - display: grid; - grid-template-columns: auto 1fr; - gap: 0 var(--spacing-8); - text-align: left; -} - -.m02-cpanel__key { - font-variant-numeric: tabular-nums; - font-weight: 600; -} - -.m02-cpanel__name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.m02-cpanel__meta { - grid-column: 1 / -1; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -/* 고른 줄 + 바로 아래 [이 열에 쓰기] */ -.m02-cpanel__pick { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding-bottom: var(--spacing-4); -} - -.m02-cpanel__pick > .ui-btn { - align-self: flex-end; -} - -/* 이 열이 쓰는 조합 — 테두리로 늘 보임(고름 is-active 와 따로) */ -.m02-cpanel__row.is-current { - box-shadow: inset 3px 0 0 var(--color-primary); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts deleted file mode 100644 index 05c6613c2..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_ComboPanel.ts - * 면적산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) · - * 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`). - * · 「이 열의 조합」 — 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) · - * 넣는 쪽이 `setMine` 으로 갈아 끼움 - * · 조합 목록 — 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) · - * 찾기 · 이 열 조합 강조 · - * 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로) - * ========================================================================== */ - -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createPanelResizer } from "@ui/ui_template_resizer"; -import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; -import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; -import { getActiveLayer, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; -import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; -import "./M02_MasterTemplete_ComboPanel.css"; - -const TEXT = { - handle: "조합", - mine: "이 열의 조합", - noneArea: "이 열에 일위대가 조합 없음", - listArea: "일위대가 조합 목록", - search: "키 · 이름으로 찾기", - logics: "로직 {n}", - origin: "본뜬 {value}", - current: "이 열", - empty: "맞는 조합 없음", - none: "조합 없음", - use: "이 열에 쓰기", - useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로", - useOk: "바꿈", - used: "이 열이 {value} 을 씀", - loadFailed: "조합 목록을 못 받음 — {value}", - failed: "이 열에 못 씀 — {value}", -}; - -const fill = (text: string, value: string | number): string => - text.replace("{n}", String(value)).replace("{value}", String(value)); - -/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */ -const MIN_WIDTH = 280; -const MAX_RATIO = 0.6; - -interface Row { - 키: string; - 이름: string; - 수: number; - 본뜬키: string | null; -} - -export interface ComboPanelOptions { - kind: "area"; - /** 집계표 열 id */ - column: string; - isAdmin: boolean; - /** [이 열에 쓰기] 뒤 — 알약 · 「이 열의 조합」을 새 조합으로 다시 그림 */ - onUsed: () => Promise | void; -} - -export interface ComboPanel { - root: HTMLElement; - /** 「이 열의 조합」 부분을 갈아 끼움(조합 줄) */ - setMine: (node: HTMLElement | null) => void; - /** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */ - refresh: () => Promise; - destroy: () => void; -} - -export function buildComboPanel(opts: ComboPanelOptions): ComboPanel { - let rows: Row[] = []; - let current: string | null = null; - let picked: string | null = null; - let q = ""; - let seq = 0; - - const card = (title: string, ...children: HTMLElement[]): HTMLElement => - el("section", { - className: "ui-collapsible ui-sidebar-section ui-sidebar-card", - children: [el("h3", { className: "ui-collapsible__title", text: title }), ...children], - }); - const mine = el("div", { className: "m02-cpanel__mine" }); - const find = createInputField({ type: "search", placeholder: TEXT.search }); - const list = el("div", { className: "ui-sidebar-list m02-cpanel__list" }); - const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" }); - const body = el("div", { - className: "m02-cpanel__body ui-sidebar-panel", - children: [card(TEXT.mine, mine), card(TEXT.listArea, find.root, list)], - }); - attachCollapsible(body); - - const root = el("aside", { className: "m02-cpanel", attrs: { "data-kind": opts.kind } }); - const handle = createWorkflowPanelHandle("side", "right", TEXT.handle); - // 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭 - const resizer = createPanelResizer({ - axis: "horizontal", - target: root, - cssVar: "--m02-cpanel-width", - direction: -1, - min: MIN_WIDTH, - max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO, - storageKey: stateKey("m02-combo-panel-width") ?? undefined, - }); - root.append(handle.root, resizer.root, body); - const setCollapsed = (collapsed: boolean): void => { - root.classList.toggle("is-collapsed", collapsed); - handle.setOpen(!collapsed); - writeStateRaw("m02-combo-panel-collapsed", String(collapsed)); - }; - handle.root.addEventListener("click", () => - setCollapsed(!root.classList.contains("is-collapsed")), - ); - setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true"); - - find.input.addEventListener("input", () => { - q = find.input.value.trim().toLowerCase(); - paint(); - }); - - function paint(): void { - const shown = rows.filter((r) => !q || `${r.키} ${r.이름}`.toLowerCase().includes(q)); - list.replaceChildren( - ...(shown.length - ? shown.map((r) => { - const meta = [ - fill(TEXT.logics, r.수), - r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "", - r.키 === current ? TEXT.current : "", - ]; - const b = el("button", { - className: - "m02-side__item ui-sidebar-row m02-cpanel__row" + - (r.키 === picked ? " is-active" : "") + - (r.키 === current ? " is-current" : ""), - attrs: { type: "button", "data-combo-key": r.키, title: `${r.키} ${r.이름}` }, - children: [ - el("span", { className: "m02-cpanel__key", text: r.키 }), - el("span", { className: "m02-cpanel__name", text: r.이름 }), - el("span", { - className: "m02-cpanel__meta", - text: meta.filter(Boolean).join(" · "), - }), - ], - }); - b.addEventListener("click", () => { - picked = r.키 === picked ? null : r.키; - paint(); - }); - // [이 열에 쓰기]는 고른 줄 바로 아래(목록 안) — 목록 밖 단추가 스크롤된 줄 글과 겹치지 않게 - return opts.isAdmin && r.키 === picked && r.키 !== current - ? el("div", { className: "m02-cpanel__pick", children: [b, use] }) - : b; - }) - : [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]), - ); - use.disabled = false; - if (use.isConnected) use.scrollIntoView({ block: "nearest" }); - } - - async function refresh(): Promise { - const mineSeq = ++seq; - try { - const [base, got] = await Promise.all([ - readColumnBase(opts.column), - fetchUsableCombos(getActiveLayer()).then((cs) => - cs.map((c) => ({ - 키: c.키, - 이름: c.이름, - 수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴 - 본뜬키: c.본뜬키 ?? null, - })), - ), - ]); - if (mineSeq !== seq) return; - current = base.일위대가 ?? null; - rows = got; - } catch (error) { - if (mineSeq !== seq) return; - showToast(fill(TEXT.loadFailed, reasonOf(error)), "error"); - } - if (picked === current) picked = null; - paint(); - } - - use.addEventListener("click", async () => { - const row = rows.find((r) => r.키 === picked); - if (!row || row.키 === current) return; - if (!(await showConfirmDialog(fill(TEXT.useAsk, `${row.키} ${row.이름}`), TEXT.useOk))) return; - use.disabled = true; - try { - const got = await setColumnCombo(opts.column, { - 종류: "일위대가", - 키: row.키, - }); - showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success"); - picked = null; - await opts.onUsed(); - await refresh(); - } catch (error) { - showToast(fill(TEXT.failed, reasonOf(error)), "error"); - paint(); - } - }); - - paint(); - void refresh(); - return { - root, - setMine: (node) => - mine.replaceChildren(node ?? el("p", { className: "m02-side__empty", text: TEXT.noneArea })), - refresh, - destroy: () => { - ++seq; - resizer.dispose(); - root.remove(); - }, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts deleted file mode 100644 index a352532ad..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Combo_Api.ts - * 조합 고치기 화면이 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 · - * 서버 까닭 글. 갈라 내기 길(`branchCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`. - * ========================================================================== */ - -import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch"; - -/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */ -const STRUCTURE_TABLE = "구조물집계표"; - -/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */ -export interface ColumnBase { - 일위대가: string | null; - 다른열일위대가: number; -} - -/** 서버가 준 까닭 글 */ -export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : ""); - -/** 집계표(시스템 층)에서 이 열의 일위대가 키와 같이 쓰는 다른 열 수 */ -export async function readColumnBase(column: string): Promise { - type Col = { id: string; 일위대가?: string | null }; - const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch( - () => null, - ); - const cols = ((table?.문서 as { 열?: Col[] } | null)?.열 ?? []) as Col[]; - const mine = cols.find((c) => c.id === column); - const same = (key: string | null | undefined, pick: (c: Col) => string | null | undefined) => - key ? cols.filter((c) => c.id !== column && pick(c) === key).length : 0; - return { - 일위대가: mine?.일위대가 || null, - 다른열일위대가: same(mine?.일위대가, (c) => c.일위대가), - }; -} diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 99585c6a5..67678a9d9 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -170,12 +170,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 ) -def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이(): - """좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음.""" - cpanel = _text(M02 / "M02_MasterTemplete_ComboPanel.ts") +def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이(): + """좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음(39-4 · 옛 조합 패널 지움).""" + apanel = _text(M02 / "M02_MasterTemplete_AreaPanel.ts") for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"): - assert name in cpanel, name - assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel + assert name in apanel, name + assert "ui-sidebar-card" in apanel and "setColumnCombo" not in apanel + for gone in ("ComboPanel.ts", "ComboPanel.css", "Combo.ts", "Combo.css", "Combo_Api.ts"): + assert not (M02 / f"M02_MasterTemplete_{gone}").exists(), gone + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "branchCombo" not in api and "setColumnCombo" not in api main = _text(MAIN) assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로 assert "matPanel" not in main # 자재 조합 판은 없앰 From 1cce06780e8858d1d60105d3e4240f763f0f7db6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 20:47:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor(M02):=20=EC=98=9B=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20Labor=5FEdit.ts=20=EC=A7=80=EC=9B=80=20=E2=80=94=20?= =?UTF-8?q?=EA=B7=B8=EA=B2=83=EB=A7=8C=20=EC=93=B0=EB=8D=98=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B8=20=ED=95=A8=EC=88=98(newCombo=20=C2=B7=20?= =?UTF-8?q?editCombo=20=C2=B7=20deleteCombo=20=C2=B7=20fetchComboInputs=20?= =?UTF-8?q?=C2=B7=20fetchLibLogic=20=C2=B7=20fetchLibAuto)=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=80=20=EA=B8=80=2017=20=C2=B7=20CSS(=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20flash)=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쓰는 곳 0 은 grep 으로 확인한 것만 · Labor_Pick · View · Detail · Rows · Input · Diagram 은 남김 - 시험: test_ui_loading 의 Labor_Edit 줄 → 실무문서 상세 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 16 +- .../M02_MasterTemplete_Labor_Api.ts | 49 --- .../M02_MasterTemplete_Labor_Diagram.css | 13 - .../M02_MasterTemplete_Labor_Edit.ts | 404 ------------------ .../M02_MasterTemplete_Labor_Text.ts | 23 - resources/tester/test_ui_loading.py | 6 +- 6 files changed, 3 insertions(+), 508 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index a5a234504..2ddad1ccc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -1,27 +1,13 @@ -/* 일위대가 조합 화면 — 표는 칸 폭을 채움 · 미리 보기 금액은 오른쪽 맞춤 · +/* 일위대가 화면 — 표는 칸 폭을 채움 · 흐린 안내 글은 보조 글색(뮤트 글색은 밝은 테마에서 대비 3.1 로 모자람) */ .m01-combo .m01-master__grid { width: 100%; } -.m01-combo [data-combo="preview"] th:not(:first-child), -.m01-combo [data-combo="preview"] td:not(:first-child) { - text-align: right; -} - .m01-combo .m01-logic__muted { color: var(--color-text-secondary); } -/* 조합 목록 — 공용 표 모양(줄 선 · 머리 줄) · 줄을 누르면 상세(PLAN 30-5) */ -.m02-labor__list tbody tr { - cursor: pointer; -} - -.m02-labor__list tbody tr:hover { - background: var(--color-mist-violet); -} - /* 일위대가 상세(39-3) — 담은 로직 · 자재목록 표 · 금액 칸 두 줄(위 기준 작게 · 아래 반영) */ .m02-labrow { width: 100%; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 447e53967..0edbf64a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -8,7 +8,6 @@ import { API_BASE_URL } from "@config/config_frontend"; import { ApiError, - type LogicOne, type LogicSummary, type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; @@ -126,52 +125,12 @@ export async function fetchUsableCombos( .flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer }))); } -/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ -export interface ComboQuantity { - 키: string; - 이름: string; - 단위: string; - 로직: string[]; - 배?: number; -} - -/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */ -export const fetchComboInputs = ( - key: string, -): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> => - request(`/combos/key/${enc(key)}/inputs`); - /** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */ export const fetchCombo = (key: string): Promise => request & LaborScope & { 판: string }>( `/combos/key/${enc(key)}`, ).then((d) => ({ ...d, file: "일위대가조합.json", version: d.판 })); -export const newCombo = ( - combo: Combo, - scope: LaborScope = SYSTEM_SCOPE, -): Promise<{ version: string; key: string }> => - request<{ 키: string; 판: string }>(at(scope), "POST", { combo }).then((d) => ({ - version: d.판, - key: d.키, - })); - -export const editCombo = ( - key: string, - version: string, - combo: Combo, - scope: LaborScope = SYSTEM_SCOPE, -): Promise<{ version: string }> => - request<{ 판: string }>(at(scope, `/${enc(key)}`), "PUT", { combo, 판: version }).then((d) => ({ - version: d.판, - })); - -export const deleteCombo = ( - key: string, - version: string, - scope: LaborScope = SYSTEM_SCOPE, -): Promise => request(at(scope, `/${enc(key)}`, { 판: version }), "DELETE"); - /* --- 담을 로직(읽기만 · 로그인) — M01 로직 길과 같은 모양 --- */ export const fetchLibLogics = (): Promise => request<{ logics: LogicSummary[] }>("/logic-lib/logics").then((d) => d.logics); @@ -179,14 +138,6 @@ export const fetchLibLogics = (): Promise => export const fetchLibLogicSubs = (): Promise => request<{ subs: SubBrief[] }>("/logic-lib/subs").then((d) => d.subs); -export const fetchLibLogic = (key: string): Promise => - request(`/logic-lib/logic?key=${enc(key)}`); - -export const fetchLibAuto = (key: string): Promise> => - request<{ 값?: Record }>(`/logic-lib/auto?key=${enc(key)}`) - .then((d) => d.값 ?? {}) - .catch(() => ({})); - /* --- 일위대가 문서(39 · 구조물 열마다 `labor/<열>.json` · 틀 = sub7 39-1a) --- */ /** 손 입력 수량 — `null` 이면 면적산출 알약 연결(칸)을 따름 · 손이 칸보다 앞섬 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css index 67a16d23a..41743b472 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.css @@ -292,19 +292,6 @@ color: var(--color-text-muted); } -/* 다이어그램에서 눌러 찾아온 미리 보기 입력 칸 */ -.m02-labor__flash { - outline: 3px solid var(--color-primary); - outline-offset: 2px; - animation: m02-labor-flash 0.4s ease-in-out 3; -} - -@keyframes m02-labor-flash { - 50% { - outline-color: transparent; - } -} - .m02-labor__dg > summary { cursor: pointer; font-weight: var(--font-weight-bold); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts deleted file mode 100644 index dfa1780f1..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ /dev/null @@ -1,404 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Edit.ts - * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 - * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서)). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b) · 미리 보기 · - * 다이어그램은 39-3 에 구조물 열 일위대가 상세(`Labor_Detail`)로 감. - * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. - * ========================================================================== */ - -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { - deleteCombo, - editCombo, - fetchCombo, - fetchLibLogics, - newCombo, - type Combo, - type ComboSummary, - type LaborScope, -} from "./M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { matchText } from "./M02_MasterTemplete_TreePanel_Model"; -import { loadingError, showLoading } from "@ui/ui_template_loading"; -import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; -import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; -import "@ui/ui_template_general_layout.css"; -import "./M02_MasterTemplete_Labor.css"; - -const blankCombo = (path: string[] = []): Combo => ({ - 키: "", - 이름: "", - 구분: path[0] ?? null, - 상세구분: path[1] ?? null, - 단위: null, - 담은로직: [], - 출처: "자체", - 소유: "현장", - 본뜬키: null, - 비고: null, -}); - -/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */ -export function reason(error: unknown): string { - if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { - const d = error.detail as { errors?: string[]; stale?: string[] }; - // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 - if (d.errors) - return d.errors - .map((x) => x.replace(/^(\S+\.json · )?(U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) - .join(" / "); - if (d.stale) return tc("Stale"); - } - return error instanceof Error ? error.message : tc("Load_Failed"); -} - -export interface LaborEditOptions { - scope: LaborScope; - canEdit: boolean; - /** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */ - onSaved: (key: string | null) => Promise; - /** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */ - onDirty: (dirty: boolean) => void; -} - -export interface LaborEditor { - root: HTMLElement; - /** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */ - setList: (items: ComboSummary[]) => void; - open: (key: string) => Promise; - /** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */ - startNew: (path: string[]) => void; - showList: () => void; - /** 「새 조합」 — 제목 줄 오른쪽 끝에 붙임(읽기 층 = null) */ - newButton: HTMLElement | null; - isDirty: () => boolean; - /** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */ - current: () => string | null; - /** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */ - setQuery: (text: string) => void; - /** 조합 목록을 새로 받는 동안 칸 안 「불러오는 중」(목록 화면일 때만 · 돌려받은 함수 = 끝) */ - loading: () => () => void; -} - -export function createLaborEditor(opt: LaborEditOptions): LaborEditor { - const edit = opt.canEdit; - let items: ComboSummary[] = []; - let logics = new Map(); - /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ - let nav = 0; - /** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */ - let current: { combo: Combo; version: string; saved: string } | null = null; - let q = ""; - const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" }); - // 찾기 칸은 라이브러리 하나(상세 위 · 나무와 같은 글 · PLAN 29-6) — `setQuery` 로 받음 - const setQuery = (text: string): void => { - q = text; - if (!current) drawList(); - }; - - const logicsReady = fetchLibLogics() - .then((all) => { - logics = new Map(all.map((x) => [x.키, x])); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); - - const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved; - const markDirty = (): void => opt.onDirty(dirty()); - - /* --- 목록 --- */ - const drawList = (): void => { - nav++; - current = null; - opt.onDirty(false); - const shown = items.filter((x) => matchText([x.키, x.이름, x.구분, x.상세구분, x.단위], q)); - const heads = [ - tc("Head_Key"), - tc("Head_Name"), - tc("Sub"), - tc("Detail"), - tc("Head_Unit"), - tc("Head_Count"), - tc("Head_Origin"), - ]; - const rows = shown.map((x) => { - const tr = el("tr", { - className: "m01-master__table-row", - attrs: { "data-combo-key": x.키 }, - children: [ - x.키, - x.이름, - x.구분 ?? "", - x.상세구분 ?? "", - x.단위 ?? "", - String(x.count), - x.본뜬키 ?? tc("Origin_Self"), - ].map((v) => el("td", { text: v, attrs: { title: v } })), - }); - tr.addEventListener("click", () => void open(x.키)); - return tr; - }); - // 「새 조합」은 제목 줄 오른쪽 끝(`newButton` · PLAN 30-5) · 목록 = 공용 표 모양(줄 선 · 머리 줄) - view.replaceChildren( - ...(edit ? [] : [el("p", { className: "m01-logic__muted", text: tc("ReadOnly") })]), - shown.length - ? el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", - children: [ - el("table", { - className: "ui-general-block__table m02-labor__list", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: rows }), - ], - }), - ], - }) - : el("p", { className: "m01-logic__muted", text: tc(items.length ? "NoMatch" : "Empty") }), - ); - }; - - const open = async (key: string): Promise => { - const my = ++nav; - const stop = showLoading(view); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6) - try { - const one = await fetchCombo(key); - await logicsReady; - if (my !== nav) return; - stop(); - draw(one.combo, one.version); - } catch (error) { - if (my !== nav) return; - stop(); - view.replaceChildren(loadingError(reason(error), () => void open(key))); - } finally { - stop(); - } - }; - - /** 조합 목록을 새로 받는 동안 — 목록 화면일 때만 칸 안 표시(돌려받은 함수 = 끝) */ - const loading = (): (() => void) => (current ? () => {} : showLoading(view)); - - const startNew = (path: string[]): void => draw(blankCombo(path), ""); - - /* --- 상세 --- */ - const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => { - nav++; - current = { combo, version, saved }; - opt.onDirty(dirty()); - const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { - const f = createInputField({ type: "text", label }); - f.input.value = combo[name] ?? ""; - f.input.readOnly = !edit; - f.input.setAttribute("data-combo-field", name); - if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음 - f.input.addEventListener("input", () => { - const v = f.input.value; - if (name === "이름") combo.이름 = v; - else combo[name] = v || null; - markDirty(); - }); - return f.root; - }; - const table = el("div", { className: "m01-master__grid-wrap" }); - const drawTable = (): void => { - const move = (i: number, d: number): void => { - const j = i + d; - if (j < 0 || j >= combo.담은로직.length) return; - [combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]]; - drawTable(); - markDirty(); - }; - const trs = combo.담은로직.map((row, i) => { - const info = logics.get(row.로직); - const memo = createInputField({ type: "text" }); - memo.input.value = row.메모 ?? ""; - memo.input.readOnly = !edit; - memo.input.addEventListener("input", () => { - row.메모 = memo.input.value || null; - markDirty(); - }); - const small = (label: string, run: () => void): HTMLElement => - createButton({ label, variant: "ghost", size: "sm", onClick: run }); - const btns = el("div", { - className: "m01-logic__chips", - children: edit - ? [ - small(tc("Up"), () => move(i, -1)), - small(tc("Down"), () => move(i, 1)), - small(tc("Remove"), () => { - combo.담은로직.splice(i, 1); - drawTable(); - markDirty(); - }), - ] - : [], - }); - return el("tr", { - attrs: { "data-combo-logic": row.로직 }, - children: [ - el("td", { text: String(i + 1) }), - el("td", { text: row.로직 }), - el("td", { text: info?.이름 ?? row.로직 }), - el("td", { text: info?.결과단위 ?? "" }), - el("td", { children: [memo.root] }), - el("td", { children: [btns] }), - ], - }); - }); - const heads = [ - tc("Col_Order"), - tc("Col_Key"), - tc("Col_Name"), - tc("Col_Unit"), - tc("Col_Memo"), - tc("Col_Move"), - ]; - table.replaceChildren( - el("table", { - className: "m01-master__grid", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: trs }), - ], - }), - ...(trs.length ? [] : [el("p", { className: "m01-logic__muted", text: tc("NoLogic") })]), - ); - }; - - const add = createButton({ - label: tc("AddLogic"), - variant: "filled", - onClick: () => - openLogicPicker((x) => { - if (!logics.has(x.키)) logics.set(x.키, x); - combo.담은로직.push({ 로직: x.키, 메모: null }); - drawTable(); - markDirty(); - }), - }); - const save = createButton({ - label: tc("Save"), - variant: "filled", - onClick: async () => { - if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); - save.disabled = true; // 연달아 눌러도 한 번만 - try { - let key = combo.키; - if (key) await editCombo(key, version, combo, opt.scope); - else key = (await newCombo(combo, opt.scope)).key; - showToast(tc("Saved"), "success"); - current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음 - opt.onDirty(false); - await opt.onSaved(key); - await open(key); - } catch (error) { - showToast(reason(error), "error"); - } finally { - save.disabled = false; - } - }, - }); - const remove = createButton({ - label: tc("Delete"), - variant: "ghost", - onClick: async () => { - if ( - !combo.키 || - !(await showConfirmDialog(`${combo.키} ${combo.이름} — ${tc("DeleteAsk")}`)) - ) - return; - try { - await deleteCombo(combo.키, version, opt.scope); - showToast(tc("Deleted"), "success"); - current = null; - await opt.onSaved(null); - drawList(); - } catch (error) { - showToast(reason(error), "error"); - } - }, - }); - const back = createButton({ - label: "←", - variant: "ghost", - onClick: async () => { - if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return; - drawList(); - }, - }); - const origin = createInputField({ type: "text", label: tc("Head_Origin") }); - origin.input.value = combo.본뜬키 ?? tc("Origin_Self"); - origin.input.readOnly = true; - origin.input.setAttribute("data-combo-origin", combo.본뜬키 ?? ""); - view.replaceChildren( - el("div", { - className: "m01-logic__section-head", - children: [ - back, - el("h3", { text: combo.키 || tc("New") }), - ...(edit ? [remove, save] : []), - ], - }), - el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01lab__info", - children: [ - field(tc("Head_Name"), "이름"), - field(tc("Sub"), "구분"), - field(tc("Detail"), "상세구분"), - field(tc("Head_Unit"), "단위"), - field(tc("Head_Note"), "비고"), - origin.root, - ], - }), - ], - }), - el("section", { - className: "m01lab__box m01-logic__section", - children: [ - el("div", { - className: "m01-logic__section-head", - children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])], - }), - table, - ], - }), - ); - drawTable(); - }; - - view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); - return { - root: view, - setList: (next) => { - items = next; - if (!current) drawList(); - }, - open, - startNew, - showList: drawList, - newButton: edit - ? createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) }) - : null, - isDirty: dirty, - current: () => current?.combo.키 ?? null, - setQuery, - loading, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 5521c55d9..cba1e88bb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -7,9 +7,6 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["일위대가 조합", "Unit-price combination"], - Library: ["일위대가 라이브러리", "Unit-price library"], - AddCombo: ["조합 추가", "Add combination"], - ComboName: ["조합 이름", "Combination name"], ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"], Sub: ["구분", "Category"], Detail: ["상세구분", "Detail"], @@ -18,36 +15,19 @@ const TEXT = { New: ["새 조합", "New combination"], Delete: ["지우기", "Delete"], Save: ["저장", "Save"], - DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"], Saved: ["저장함", "Saved"], Deleted: ["지움", "Deleted"], Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"], Loading: ["불러오는 중…", "Loading…"], - LeaveAsk: [ - "저장 안 한 고침이 있음 — 로직 상세로 가면 이 화면에서는 그대로 두고 옮겨 감. 갈까요?", - "Unsaved edits — go to the logic anyway?", - ], - BackAsk: [ - "저장 안 한 고침이 있음 — 버리고 목록으로 갈까요?", - "Unsaved edits — discard and go back to the list?", - ], Search: ["이름 · 키로 찾기", "Search by name or key"], NoMatch: ["찾은 조합 없음 — 거름 · 찾기 글을 바꿔 봄", "No match — change the filter or search"], NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], - Head_Key: ["키", "Key"], - Head_Name: ["이름", "Name"], - Head_Unit: ["단위", "Unit"], - Head_Note: ["비고", "Note"], - Head_Count: ["로직 수", "Logics"], - Head_Origin: ["본뜬키", "Copied from"], Origin_Self: ["원본", "Original"], Logics_Title: ["담은 로직", "Logics in it"], Col_Order: ["차례", "No."], - Col_Key: ["로직 키", "Logic key"], Col_Name: ["이름", "Name"], Col_Unit: ["결과 단위", "Result unit"], - Col_Memo: ["메모", "Memo"], Col_Move: ["순서", "Move"], Up: ["올리기", "Up"], Down: ["내리기", "Down"], @@ -70,7 +50,6 @@ const TEXT = { Prev_Expense: ["경비", "Expense"], Prev_Sum: ["합계", "Total"], // 일위대가 상세(39-3) — 구조물 열마다 하나 - Labor_Title: ["일위대가", "Unit price"], Labor_Unit: ["단위", "Unit"], Labor_Origin: ["본뜬 곳", "Copied from"], Labor_Mats: ["자재목록", "Materials"], @@ -81,7 +60,6 @@ const TEXT = { Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], Labor_AppliedSum: ["반영 금액", "Applied amount"], - Labor_Calc_Wait: ["계산 중…", "Calculating…"], Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], Labor_Empty: ["빈 값 {n}", "{n} empty"], Labor_Lost: [ @@ -106,7 +84,6 @@ const TEXT = { In_Ok: ["확인", "OK"], In_Cancel: ["취소", "Cancel"], In_Close: ["닫기", "Close"], - In_Loading: ["입력 목록 받는 중…", "Loading inputs…"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/test_ui_loading.py b/resources/tester/test_ui_loading.py index 87b8ddfa3..8ea31872e 100644 --- a/resources/tester/test_ui_loading.py +++ b/resources/tester/test_ui_loading.py @@ -77,12 +77,10 @@ def test_실패_글과_다시_불러오기_M02_자리(): main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "const stop = showLoading(host);" in main assert "loadingError(why, () => void open(next))" in main - edit = _text(M02 / "M02_MasterTemplete_Labor_Edit.ts") - assert "loadingError(reason(error), () => void open(key))" in edit - assert "(current ? () => {} : showLoading(view))" in edit - # 좌측 일위대가 조합 나무(Labor.ts)는 39-2 에 지움 — 그 목록 자리는 실무문서 화면(같은 꼴) + # 옛 조합 나무 · 상세(Labor.ts · Labor_Edit.ts)는 39 에 지움 — 그 자리는 실무문서(같은 꼴) practice = _text(M02 / "M02_MasterTemplete_Practice.ts") assert "const stop = showLoading(view);" in practice assert "loadingError(why(error), () => void drawList())" in practice + assert "loadingError(why(error), () => void drawOne(key))" in practice assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts") assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts")