From 0c3b089719422343e54ee5bc6f6f4ba9a41be54c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 18:51:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=A1=B0=ED=95=A9=20=EC=A4=84=20=C2=B7=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=C2=B7=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=EC=82=B0=EC=B6=9C=20=EC=9E=90=EC=9E=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=98=86=20=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 면적산출 편집 화면 알약 막대 위 한 줄(일위대가 키 · 이름 · 담은 로직 수 · 같이 쓰는 다른 열 수 · 본뜬키) + [조합 고치기](시스템 관리자만) - 담은 로직 표(더하기 = M01 로직 찾기 · 빼기 · 차례 · 메모) → [적용] 때 갈라 내기 모달(이름 자동 미리 채움) → 새 조합 알림 → 알약만 다시 받음(시트 고침 유지) - 재료산출 엑셀 옆 접히는 판에 자재 조합 담은 자재 표 · 더하기 · 빼기(이은 칸이면 연결도 풂 확인) · [적용] 모달 - 서버 길(branchCombo · branchMaterialCombo · 자재 조합 읽기)은 아직 안 올라와 M02_MasterTemplete_Combo_Api.ts 한 곳에 가정 모양으로 둠 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq --- .../M02_MasterTemplete_Combo.css | 143 +++++++++ .../M02_MasterTemplete_Combo.ts | 267 +++++++++++++++++ .../M02_MasterTemplete_Combo_Api.ts | 100 +++++++ .../M02_MasterTemplete_Materials.ts | 275 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 94 ++++-- 5 files changed, 862 insertions(+), 17 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Materials.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.css b/M02_MasterTemplete/M02_MasterTemplete_Combo.css new file mode 100644 index 00000000..339b3d7f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.css @@ -0,0 +1,143 @@ +/* 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; +} + +/* 재료산출 — 엑셀 옆 접히는 판 */ +.m02-main__host:has(> .ss) > .m02-mat { + flex: none; +} + +.m02-mat { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + width: min(30rem, 42%); + min-height: 0; + padding: var(--spacing-8); + border-left: 1px solid var(--color-border); + overflow: auto; +} + +.m02-mat.is-collapsed { + width: auto; +} + +.m02-mat.is-collapsed .m02-mat__body { + display: none; +} + +.m02-mat > .ui-btn { + align-self: flex-start; +} + +.m02-mat__body { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m02-mat__title { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + color: var(--color-text); +} + +.m02-mat__row { + display: grid; + grid-template-columns: 5.5rem minmax(6rem, 1.4fr) minmax(4rem, 1fr) 3rem 4rem auto; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) 0; + border-bottom: 1px solid var(--color-border); + overflow-wrap: anywhere; +} + +.m02-mat__row--head { + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-weight: 600; +} + +.m02-mat__foot { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m02-main__combo:empty { + display: none; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts new file mode 100644 index 00000000..645de8d4 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -0,0 +1,267 @@ +/* ============================================================================= + * M02_MasterTemplete_Combo.ts + * 면적산출 편집 화면 — 알약 막대 위 조합 줄 + [조합 고치기](시스템 관리자만). + * 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어 + * 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양. + * 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀. + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { fetchCombo, type ComboLine } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { openLogicPicker } from "../M01_MasterData/M01_MasterData_UI_Combo_Pick"; +import { + branchCombo, + readColumnBase, + reasonOf, + type BranchResult, +} 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: "고친 것이 없음", + loadFailed: "조합을 못 읽음 — {value}", + askTitle: "새 조합으로 갈라 냄", + askName: "새 조합 이름", + askOk: "새로 만듦", + askCancel: "취소", + askBody: + "이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", + askAlone: + "이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 원래 조합은 그대로", + made: "새 조합 {value} 을 만들고 이 열이 가리킴", + reused: "같은 내용의 조합 {value} 을 씀", + failed: "조합을 못 고침 — {value}", +}; + +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; + const ok = createButton({ + label: TEXT.askOk, + variant: "filled", + onClick: () => { + const value = name.input.value.trim(); + if (!value) return name.input.focus(); + finish(value); + close(); + }, + }); + 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 }); + }); +} + +/** 결과 알림 — 새로 만듦 / 같은 조합 씀 */ +export const branchToast = (r: BranchResult): void => + showToast(fill(r.새로만듦 ? TEXT.made : TEXT.reused, `${r.키} ${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 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", + 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.메모 ?? "" }, + }); + memo.addEventListener("input", () => (line.메모 = memo.value || null)); + 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 (JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) === original) { + return showToast(TEXT.unchanged, "info"); + } + const message = others ? fill(TEXT.askBody, others + 1) : TEXT.askAlone; + const name = await askBranch(message, `${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_Combo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts new file mode 100644 index 00000000..683aa4f2 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * M02_MasterTemplete_Combo_Api.ts + * 면적산출 조합 고치기 · 재료산출 자재 목록이 부르는 서버 길 — 갈라 내기 한 번에(새 조합 + 그 열만 새 키). + * ⚠ 서버(sub4 `branchCombo` · `branchMaterialCombo`) · 자재 조합 표(sub3 UM)가 아직 안 올라옴 — + * 아래 길 · 응답 모양은 PLAN 15장 · `14_조합편집검토.md` 1장 기준 가정. 올라오면 이 파일만 맞춤. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { ComboLogic } from "../M01_MasterData/M01_MasterData_UI_Combo_Api"; +import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { readTemplate } from "./M02_MasterTemplete_Api_Fetch"; + +/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */ +const STRUCTURE_TABLE = "구조물집계표"; + +export interface MaterialItem { + 코드: string; + 이름: string; + 규격: string; + 단위: string; +} + +/** 자재 조합 한 줄(UM) — 담은 자재 목록 · 본뜬키 = 갈라 내기 전 원래 조합 */ +export interface MaterialCombo { + 키: string; + 이름: string; + 담은자재: MaterialItem[]; + 본뜬키?: string | null; +} + +/** 갈라 내기 결과 — 새로만듦 false = 같은 내용의 있는 조합을 씀 */ +export interface BranchResult { + 키: string; + 이름: string; + 새로만듦: boolean; +} + +/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */ +export interface ColumnBase { + 일위대가: string | null; + 자재조합: string | null; + 다른열일위대가: number; + 다른열자재조합: number; +} + +async function request(path: string, body?: unknown): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +/** 서버가 준 까닭 글 — 422 는 검사 글 목록 */ +export function reasonOf(error: unknown): string { + if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { + const d = error.detail as { errors?: string[] }; + if (d.errors) return d.errors.join(" / "); + } + return error instanceof Error ? error.message : ""; +} + +/** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */ +export async function readColumnBase(column: string): Promise { + type Col = { id: string; 일위대가?: string | null; 자재조합?: string | null }; + const table = await readTemplate("system", "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, + 자재조합: mine?.자재조합 || null, + 다른열일위대가: same(mine?.일위대가, (c) => c.일위대가), + 다른열자재조합: same(mine?.자재조합, (c) => c.자재조합), + }; +} + +/** 고친 담은 로직으로 새 조합을 만들고 이 열만 새 조합을 가리킴(같은 내용이면 있는 조합) */ +export const branchCombo = ( + column: string, + body: { 본뜬키: string; 담은로직: ComboLogic[]; 이름: string }, +): Promise => request(`/m02/structures/${encodeURIComponent(column)}/combo`, body); + +/** 자재 조합 한 줄 읽기(M01 자재 조합 표 UM) */ +export const fetchMaterialCombo = (key: string): Promise => + request<{ combo: MaterialCombo }>(`/m01/material-combo?key=${encodeURIComponent(key)}`).then( + (d) => d.combo, + ); + +/** 고친 담은 자재로 새 자재 조합을 만들고 이 열만 가리킴(같은 내용이면 있는 조합) */ +export const branchMaterialCombo = ( + column: string, + body: { 본뜬키: string; 담은자재: MaterialItem[]; 이름: string }, +): Promise => + request(`/m02/structures/${encodeURIComponent(column)}/material-combo`, body); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts new file mode 100644 index 00000000..091442f5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -0,0 +1,275 @@ +/* ============================================================================= + * M02_MasterTemplete_Materials.ts + * 재료산출 편집 화면 — 엑셀 옆 접히는 판: 그 열의 자재 조합(UM) 담은 자재 표 + [자재 더하기] · [빼기] · [적용]. + * 자재 조합도 여러 열이 같이 쓸 수 있어 [적용] 때 일위대가처럼 모달 후 새 조합으로 갈라 냄 + * (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂. + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types"; +import { askBranch, branchToast } from "./M02_MasterTemplete_Combo"; +import { + branchMaterialCombo, + fetchMaterialCombo, + readColumnBase, + reasonOf, + type MaterialCombo, + type MaterialItem, +} from "./M02_MasterTemplete_Combo_Api"; + +const TEXT = { + toggle: "자재 목록", + combo: "자재 조합", + branched: "본뜬 조합 {value}", + none: "자재 조합 없음 — 자재를 더하면 새로 만듦", + code: "코드", + name: "이름", + spec: "규격", + unit: "단위", + cell: "이은 칸", + remove: "빼기", + add: "자재 더하기", + apply: "적용", + undo: "되돌리기", + empty: "담은 자재가 없음", + unlinkAsk: "{value} 자재는 {cell} 칸에 이어져 있음 — 빼면 연결도 풂", + unlinkOk: "빼고 연결 풂", + loadFailed: "자재 조합을 못 읽음 — {value}", + askBody: + "이 자재 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀 · 원래 조합은 그대로", + askAlone: + "이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀", + failed: "자재 조합을 못 고침 — {value}", + pickTitle: "자재 더하기", + pickClose: "닫기", + search: "자재 이름 · 규격 · 코드", + searching: "찾는 중…", + noMatch: "맞는 자재 없음", + searchFailed: "자재 목록을 못 받음", +}; + +const fill = (text: string, values: Record): string => + Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text); + +export interface MaterialPanelOptions { + /** 집계표 열 id */ + column: string; + /** 구조물 이름 — 새 이름 자동 미리 채움 */ + label: string; + isAdmin: boolean; + /** 지금 시트 문서의 연결 전부 */ + links: () => CellLink[]; + /** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */ + unlink: (codes: string[]) => void; +} + +/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름 */ +function pickMaterial(done: (item: MaterialItem) => void): void { + openModal({ + title: TEXT.pickTitle, + closeLabel: TEXT.pickClose, + dialogClass: "m02-modal m02-pick", + mount: (body, close) => { + const search = createInputField({ placeholder: TEXT.search, type: "search" }); + const list = el("div", { className: "m02-pick__list" }); + let seq = 0; + const paint = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); + try { + const hits = await searchMaterials(search.input.value.trim()); + if (mine !== seq) return; + list.replaceChildren( + ...(hits.length + ? hits.map((m) => { + const row = el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-material": m.코드 }, + children: [ + el("span", { className: "m02-pick__key", text: m.코드 }), + `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), + ], + }); + row.addEventListener("click", () => { + done(m); + close(); + }); + return row; + }) + : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), + ); + } catch { + if (mine === seq) showToast(TEXT.searchFailed, "error"); + } + }; + let timer = 0; + search.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void paint(), 250); + }); + body.append(search.root, list); + void paint(); + search.input.focus(); + }, + }); +} + +/** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */ +export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise { + const base = await readColumnBase(opts.column); + let key = base.자재조합 ?? ""; + let others = base.다른열자재조합; + let combo: (MaterialCombo & { 본뜬키?: string | null }) | null = null; + if (key) { + try { + combo = await fetchMaterialCombo(key); + } catch (error) { + showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error"); + } + } + let saved: MaterialItem[] = combo?.담은자재 ?? []; + let items: MaterialItem[] = [...saved]; + const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드)); + + const toggle = createButton({ label: "", variant: "ghost", size: "sm" }); + const title = el("div", { className: "m02-mat__title" }); + const table = el("div", { className: "m02-mat__table" }); + const foot = el("div", { className: "m02-mat__foot" }); + const body = el("div", { className: "m02-mat__body", children: [title, table, foot] }); + const root = el("aside", { className: "m02-mat", children: [toggle, body] }); + toggle.addEventListener("click", () => { + root.classList.toggle("is-collapsed"); + paintToggle(); + }); + const paintToggle = (): void => { + const open = !root.classList.contains("is-collapsed"); + toggle.querySelector(".ui-btn__label")!.textContent = + `${open ? "▸" : "◂"} ${TEXT.toggle} ${items.length}`; + }; + + const cellsOf = (code: string): string[] => + opts + .links() + .filter((l) => (l.자재?.코드 ?? l.키) === code) + .map((l) => l.칸); + + const paint = (): void => { + title.replaceChildren( + el("span", { text: key ? `${TEXT.combo} ${key} ${combo?.이름 ?? ""}`.trim() : TEXT.none }), + ...(opts.isAdmin && combo?.본뜬키 + ? [ + el("span", { + className: "m02-combo__origin", + text: fill(TEXT.branched, { value: combo.본뜬키 }), + }), + ] + : []), + ); + const head = el("div", { + className: "m02-mat__row m02-mat__row--head", + children: [TEXT.code, TEXT.name, TEXT.spec, TEXT.unit, TEXT.cell, ""].map((t) => + el("span", { text: t }), + ), + }); + const rows = items.map((m, i) => { + const cells = cellsOf(m.코드); + const acts = el("span", { className: "m02-mat__acts" }); + if (opts.isAdmin) { + const del = createButton({ label: TEXT.remove, variant: "ghost", size: "sm" }); + del.addEventListener("click", async () => { + if ( + cells.length && + !(await showConfirmDialog( + fill(TEXT.unlinkAsk, { value: m.이름, cell: cells.join(", ") }), + TEXT.unlinkOk, + )) + ) + return; + items.splice(i, 1); + removed.push(m.코드); + paint(); + }); + acts.append(del); + } + return el("div", { + className: "m02-mat__row", + attrs: { "data-material": m.코드 }, + children: [m.코드, m.이름, m.규격, m.단위, cells.join(", ")] + .map((t) => el("span", { text: t })) + .concat(acts), + }); + }); + table.replaceChildren( + head, + ...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]), + ); + paintFoot(); + paintToggle(); + }; + + /** 이번에 뺀 자재 코드 — 적용하면 이은 칸 연결도 풂 */ + let removed: string[] = []; + const paintFoot = (): void => { + if (!opts.isAdmin) return foot.replaceChildren(); + const add = createButton({ label: TEXT.add, variant: "ghost", size: "sm" }); + add.addEventListener("click", () => + pickMaterial((m) => { + if (items.some((x) => x.코드 === m.코드)) return; + items.push(m); + removed = removed.filter((c) => c !== m.코드); + paint(); + }), + ); + const buttons = [add]; + if (sig(items) !== sig(saved)) { + const apply = createButton({ label: TEXT.apply, variant: "filled", size: "sm" }); + apply.addEventListener("click", async () => { + const message = others ? fill(TEXT.askBody, { n: others + 1 }) : TEXT.askAlone; + const name = await askBranch( + message, + `${combo?.이름 ?? ""} · ${opts.label}`.replace(/^ · /, ""), + ); + if (name === null) return; + apply.disabled = true; + try { + const result = await branchMaterialCombo(opts.column, { + 본뜬키: key, + 담은자재: items, + 이름: name, + }); + branchToast(result); + if (removed.length) opts.unlink(removed); + removed = []; + key = result.키; + others = 0; + combo = await fetchMaterialCombo(key).catch(() => null); + saved = combo?.담은자재 ?? [...items]; + items = [...saved]; + paint(); + } catch (error) { + apply.disabled = false; + showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error"); + } + }); + const undo = createButton({ label: TEXT.undo, variant: "ghost", size: "sm" }); + undo.addEventListener("click", () => { + items = [...saved]; + removed = []; + paint(); + }); + buttons.push(apply, undo); + } + foot.replaceChildren(...buttons); + }; + + paint(); + return root; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 2ac3160b..9479f215 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -23,6 +23,9 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; +import { buildComboBar } from "./M02_MasterTemplete_Combo"; +import { buildMaterialPanel } from "./M02_MasterTemplete_Materials"; +import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; export interface Selection { layer: Layer; projectId: string | null; @@ -87,6 +90,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { const save = createButton({ label: L("M02_Save"), variant: "filled" }); const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); + const comboSlot = el("div", { className: "m02-main__combo" }); // 면적산출 — 알약 막대 위 조합 줄 const host = el("div", { className: "m02-main__host" }); const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너 const root = el("div", { @@ -94,6 +98,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { children: [ el("div", { className: "m02-main__head", children: [title, slot, reload, save] }), notice, + comboSlot, host, extra, ], @@ -143,6 +148,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { panel = null; extra.replaceChildren(); slot.replaceChildren(); + comboSlot.replaceChildren(); }; /** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */ @@ -159,6 +165,76 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { return made; } + /** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 · + * 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] · 재료 = 자재 목록 옆 판 */ + async function mountSheet( + doc: unknown, + kind: "area" | "material", + at: Selection, + readOnly: boolean, + ): Promise { + const mine = seq; + try { + const [{ createSpreadsheet }, pills] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + pillsFor(kind, at.name), + ]); + if (mine !== seq) return; + if (pills.notice) showNotice([pills.notice]); + const handle: SpreadsheetHandle = createSpreadsheet( + host, + doc as Parameters[1], + { + readOnly, + tabs: false, // 시트 하나 · 탭 줄 없음 + onChange: () => (dirty = !readOnly), + links: pills.links, + }, + ); + editor = handle; + if (at.layer !== "system") return; + const label = at.label ?? at.name; + if (kind === "area") { + const bar = await buildComboBar({ + column: at.name, + label, + isAdmin, + beforeEdit: async () => true, + // 알약만 다시 받음 — 저장 안 한 시트 고침은 그대로 옮김 + onApplied: async () => { + if (mine !== seq) return; + const kept = handle.getDoc(); + editor?.destroy(); + editor = null; + host.replaceChildren(); + await mountSheet(kept, kind, at, readOnly); + }, + }); + if (mine === seq) comboSlot.replaceChildren(...(bar ? [bar] : [])); + return; + } + const panel = await buildMaterialPanel({ + column: at.name, + label, + isAdmin, + links: () => handle.getDoc().시트.flatMap((s) => s.연결 ?? []), + unlink: (codes) => { + const book = handle.getDoc(); + for (const s of book.시트) { + const rest = (s.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키)); + if (rest.length) s.연결 = rest; + else delete s.연결; + } + handle.setDoc(book); + dirty = !readOnly; + }, + }); + if (mine === seq) host.append(panel); + } catch { + empty(L("M02_NoSpreadsheet")); + } + } + async function mount(doc: unknown, at: Selection): Promise { const readOnly = !canEdit(at.layer, isAdmin); host.replaceChildren(); @@ -202,23 +278,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { if (at.kind === "area" || at.kind === "material") { // 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을 // 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리) - // 위 알약 막대(sub7 `links`) — 면적 = 그 열 일위대가 입력값 · 재료 = 자재DB 찾기(`pillsFor`) - try { - const [{ createSpreadsheet }, pills] = await Promise.all([ - import("../A00_Common/spreadsheet/spreadsheet"), - pillsFor(at.kind, at.name), - ]); - if (pills.notice) showNotice([pills.notice]); - editor = createSpreadsheet(host, doc as Parameters[1], { - readOnly, - tabs: false, // 시트 하나 · 탭 줄 없음 - onChange: () => (dirty = !readOnly), - links: pills.links, - }); - } catch { - empty(L("M02_NoSpreadsheet")); - } - return; + return mountSheet(doc, at.kind, at, readOnly); } const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( DRAWING_MODS,