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:
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user