feat(M02): 조합 고치기 · 자재 목록을 실제 갈라 내기 길에 연결 · 알약 자재 항목 = 옆 판 목록 · 줄 누르면 이은 칸으로

- 가정하던 서버 함수를 실제 branchCombo · branchMaterialCombo · fetchMaterialCombo(M02 Api_Fetch)로 바꾸고 가정 파일은 열 조합 읽기만 남김
- 재료산출 알약 막대 자재 항목을 그 열 자재 조합 담은 자재로 · 알약 더하기 · 빼기(onItemsChange)와 옆 판 표를 같은 목록으로 맞춤
- 옆 판 줄을 누르면 이은 알약을 눌러 그 칸으로 이동(깜빡임) · 옆 판에서 고치면 알약 막대만 다시 만듦(시트 고침 유지)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWHNLQYPZN7kKf1rzrrRZq
This commit is contained in:
2026-09-28 19:19:27 +09:00
co-authored by Claude Sonnet 5
parent 76bb9aec55
commit e06d43a6bc
5 changed files with 134 additions and 108 deletions
@@ -80,6 +80,7 @@
}
.m02-mat {
order: 2;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
@@ -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<string
}
/** 결과 알림 — 새로 만듦 / 같은 조합 씀 */
export const branchToast = (r: BranchResult): void =>
export const branchToast = (r: BranchedCombo): void =>
showToast(fill(r.새로만듦 ? TEXT.made : TEXT.reused, `${r.키} ${r.이름}`), "success");
export interface ComboBarOptions {
@@ -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<T>(path: string, body?: unknown): Promise<T> {
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<ColumnBase> {
@@ -79,22 +35,3 @@ export async function readColumnBase(column: string): Promise<ColumnBase> {
다른열자재조합: same(mine?.자재조합, (c) => c.자재조합),
};
}
/** 고친 담은 로직으로 새 조합을 만들고 이 열만 새 조합을 가리킴(같은 내용이면 있는 조합) */
export const branchCombo = (
column: string,
body: { 본뜬키: string; 담은로직: ComboLogic[]; 이름: string },
): Promise<BranchResult> => request(`/m02/structures/${encodeURIComponent(column)}/combo`, body);
/** 자재 조합 한 줄 읽기(M01 자재 조합 표 UM) */
export const fetchMaterialCombo = (key: string): Promise<MaterialCombo> =>
request<{ combo: MaterialCombo }>(`/m01/material-combo?key=${encodeURIComponent(key)}`).then(
(d) => d.combo,
);
/** 고친 담은 자재로 새 자재 조합을 만들고 이 열만 가리킴(같은 내용이면 있는 조합) */
export const branchMaterialCombo = (
column: string,
body: { 본뜬키: string; 담은자재: MaterialItem[]; 이름: string },
): Promise<BranchResult> =>
request(`/m02/structures/${encodeURIComponent(column)}/material-combo`, body);
@@ -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, string | number>): 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<MaterialState> {
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<HTMLElement> {
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<HT
if (
cells.length &&
!(await showConfirmDialog(
fill(TEXT.unlinkAsk, { value: m.이름, cell: cells.join(", ") }),
fill(TEXT.unlinkAsk, { value: m.이름 ?? m.코드, cell: cells.join(", ") }),
TEXT.unlinkOk,
))
)
@@ -196,16 +232,19 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise<HT
items.splice(i, 1);
removed.push(m.코드);
paint();
opts.onChange(items);
});
acts.append(del);
}
return el("div", {
className: "m02-mat__row",
const row = el("div", {
className: `m02-mat__row${cells.length ? " is-linked" : ""}`,
attrs: { "data-material": m.코드 },
children: [m.코드, m.이름, m.규격, m.단위, cells.join(", ")]
children: [m.코드, m.이름 ?? "", m.규격 ?? "", m.단위 ?? "", cells.join(", ")]
.map((t) => 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<HT
items.push(m);
removed = removed.filter((c) => c !== m.코드);
paint();
opts.onChange(items);
}),
);
const buttons = [add];
@@ -264,6 +304,7 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise<HT
items = [...saved];
removed = [];
paint();
opts.onChange(items);
});
buttons.push(apply, undo);
}
@@ -271,5 +312,11 @@ export async function buildMaterialPanel(opts: MaterialPanelOptions): Promise<HT
};
paint();
return root;
return {
root,
setItems: (list) => {
items = [...list];
paint();
},
};
}
@@ -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<void> {
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<typeof createSpreadsheet>[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<HTMLElement>(`.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"));
}