diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.css b/M02_MasterTemplete/M02_MasterTemplete_Combo.css index 339b3d7f..096e38a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.css @@ -80,6 +80,7 @@ } .m02-mat { + order: 2; display: flex; flex-direction: column; gap: var(--spacing-8); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index 645de8d4..873ebeb9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -10,12 +10,8 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e 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 { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch"; +import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_Combo.css"; const TEXT = { @@ -100,7 +96,7 @@ export function askBranch(message: string, defaultName: string): Promise +export const branchToast = (r: BranchedCombo): void => showToast(fill(r.새로만듦 ? TEXT.made : TEXT.reused, `${r.키} ${r.이름}`), "success"); export interface ComboBarOptions { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts index 683aa4f2..1eb5e2b8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts @@ -1,40 +1,14 @@ /* ============================================================================= * M02_MasterTemplete_Combo_Api.ts - * 면적산출 조합 고치기 · 재료산출 자재 목록이 부르는 서버 길 — 갈라 내기 한 번에(새 조합 + 그 열만 새 키). - * ⚠ 서버(sub4 `branchCombo` · `branchMaterialCombo`) · 자재 조합 표(sub3 UM)가 아직 안 올라옴 — - * 아래 길 · 응답 모양은 PLAN 15장 · `14_조합편집검토.md` 1장 기준 가정. 올라오면 이 파일만 맞춤. + * 조합 고치기 · 자재 목록 화면이 함께 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 · + * 서버 까닭 글. 갈라 내기 길(`branchCombo` · `branchMaterialCombo` · `fetchMaterialCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`. * ========================================================================== */ -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; @@ -43,26 +17,8 @@ export interface ColumnBase { 다른열자재조합: 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 const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : ""); /** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */ export async function readColumnBase(column: string): Promise { @@ -79,22 +35,3 @@ export async function readColumnBase(column: string): Promise { 다른열자재조합: 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 index 091442f5..5499479b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -14,16 +14,16 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; 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"; +} from "./M02_MasterTemplete_Api_Fetch"; +import { askBranch, branchToast } from "./M02_MasterTemplete_Combo"; +import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; const TEXT = { toggle: "자재 목록", @@ -59,9 +59,53 @@ const TEXT = { const fill = (text: string, values: Record): string => Object.entries(values).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text); +/** 이 열의 자재 조합 — 키(없으면 빈 글) · 같이 쓰는 다른 열 수 · 담은 자재 */ +export interface MaterialState { + key: string; + others: number; + combo: MaterialCombo | null; +} + +export async function loadMaterialState(column: string): Promise { + const base = await readColumnBase(column); + const key = base.자재조합 ?? ""; + let combo: MaterialCombo | null = null; + if (key) { + try { + combo = await fetchMaterialCombo(key); + } catch (error) { + showToast(fill(TEXT.loadFailed, { value: reasonOf(error) }), "error"); + } + } + return { key, others: base.다른열자재조합, combo }; +} + +/** 알약 막대 자재 항목 ↔ 담은 자재 — 알약 키 = 자재 코드 */ +export const toPills = (items: MaterialItem[]): LinkItem[] => + items.map((m) => { + const 자재 = { + 코드: m.코드, + 이름: m.이름 ?? "", + 규격: m.규격 ?? "", + 단위: m.단위 ?? "", + }; + return { 키: m.코드, 이름: 자재.이름, 단위: 자재.단위, 자재 }; + }); + +export interface MaterialPanel { + root: HTMLElement; + /** 알약 막대에서 목록이 바뀜 — 표만 다시 그림(알약은 이미 맞음) */ + setItems: (items: MaterialItem[]) => void; +} + export interface MaterialPanelOptions { /** 집계표 열 id */ column: string; + state: MaterialState; + /** 표에서 더하기 · 빼기 · 되돌리기 — 알약 막대를 이 목록으로 맞춤 */ + onChange: (items: MaterialItem[]) => void; + /** 이은 칸이 있는 줄을 누름 — 알약을 눌러 그 칸으로 감(깜빡임) */ + goTo: (code: string) => void; /** 구조물 이름 — 새 이름 자동 미리 채움 */ label: string; isAdmin: boolean; @@ -123,18 +167,10 @@ function pickMaterial(done: (item: MaterialItem) => void): void { } /** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */ -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"); - } - } +export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { + let key = opts.state.key; + let others = opts.state.others; + let combo = opts.state.combo; let saved: MaterialItem[] = combo?.담은자재 ?? []; let items: MaterialItem[] = [...saved]; const sig = (list: MaterialItem[]): string => JSON.stringify(list.map((m) => m.코드)); @@ -188,7 +224,7 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise el("span", { text: t })) .concat(acts), }); + if (cells.length) row.addEventListener("click", () => opts.goTo(m.코드)); + return row; }); table.replaceChildren( head, @@ -226,6 +265,7 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise c !== m.코드); paint(); + opts.onChange(items); }), ); const buttons = [add]; @@ -264,6 +304,7 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise { + items = [...list]; + paint(); + }, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 9479f215..93e60594 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -24,8 +24,17 @@ import { type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; -import { buildMaterialPanel } from "./M02_MasterTemplete_Materials"; +import { + buildMaterialPanel, + loadMaterialState, + toPills, + type MaterialPanel, + type MaterialState, +} from "./M02_MasterTemplete_Materials"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import type { MaterialItem } from "./M02_MasterTemplete_Api_Fetch"; export interface Selection { layer: Layer; projectId: string | null; @@ -110,6 +119,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { let editor: EditorHandle | null = null; let sheet: SheetEditorHandle | null = null; let panel: StructurePanelHandle | null = null; + let sheetHandle: SpreadsheetHandle | null = null; + let matPanel: MaterialPanel | null = null; + let matState: MaterialState | null = null; let seq = 0; let dirty = false; @@ -144,6 +156,9 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { editor?.destroy(); editor = null; sheet = null; + sheetHandle = null; + matPanel = null; + matState = null; panel?.destroy(); panel = null; extra.replaceChildren(); @@ -166,21 +181,36 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { } /** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 · - * 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] · 재료 = 자재 목록 옆 판 */ + * 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] · + * 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판 + * (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */ async function mountSheet( doc: unknown, kind: "area" | "material", at: Selection, readOnly: boolean, + pillItems?: MaterialItem[], ): Promise { const mine = seq; try { + const master = at.layer === "system" && kind === "material"; + if (master && !matState) matState = await loadMaterialState(at.name); const [{ createSpreadsheet }, pills] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), - pillsFor(kind, at.name), + master ? null : pillsFor(kind, at.name), ]); if (mine !== seq) return; - if (pills.notice) showNotice([pills.notice]); + if (pills?.notice) showNotice([pills.notice]); + const links: LinkOptions = + pills?.links ?? + ({ + 항목: toPills(pillItems ?? matState?.combo?.담은자재 ?? []), + 모드: "자재", + 자재찾기: searchMaterials, + // 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로 + onItemsChange: (list) => + matPanel?.setItems(list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []))), + } satisfies LinkOptions); const handle: SpreadsheetHandle = createSpreadsheet( host, doc as Parameters[1], @@ -188,16 +218,16 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { readOnly, tabs: false, // 시트 하나 · 탭 줄 없음 onChange: () => (dirty = !readOnly), - links: pills.links, + links, }, ); editor = handle; + sheetHandle = handle; if (at.layer !== "system") return; - const label = at.label ?? at.name; if (kind === "area") { const bar = await buildComboBar({ column: at.name, - label, + label: at.label ?? at.name, isAdmin, beforeEdit: async () => true, // 알약만 다시 받음 — 저장 안 한 시트 고침은 그대로 옮김 @@ -213,23 +243,38 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { if (mine === seq) comboSlot.replaceChildren(...(bar ? [bar] : [])); return; } - const panel = await buildMaterialPanel({ + if (matPanel || !matState) return; + matPanel = buildMaterialPanel({ column: at.name, - label, + label: at.label ?? at.name, isAdmin, - links: () => handle.getDoc().시트.flatMap((s) => s.연결 ?? []), + state: matState, + links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [], + // 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로) + onChange: (items) => { + const kept = sheetHandle?.getDoc(); + if (!kept) return; + editor?.destroy(); + editor = null; + void mountSheet(kept, kind, at, readOnly, items); + }, + goTo: (code) => + host + .querySelector(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`) + ?.click(), unlink: (codes) => { - const book = handle.getDoc(); + const book = sheetHandle?.getDoc(); + if (!book) return; 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); + sheetHandle?.setDoc(book); dirty = !readOnly; }, }); - if (mine === seq) host.append(panel); + host.append(matPanel.root); } catch { empty(L("M02_NoSpreadsheet")); }