feat(M02): 면적산출 조합 줄 · 조합 고치기 · 재료산출 자재 목록 옆 판

- 면적산출 편집 화면 알약 막대 위 한 줄(일위대가 키 · 이름 · 담은 로직 수 · 같이 쓰는 다른 열 수 · 본뜬키) + [조합 고치기](시스템 관리자만)
- 담은 로직 표(더하기 = M01 로직 찾기 · 빼기 · 차례 · 메모) → [적용] 때 갈라 내기 모달(이름 자동 미리 채움) → 새 조합 알림 → 알약만 다시 받음(시트 고침 유지)
- 재료산출 엑셀 옆 접히는 판에 자재 조합 담은 자재 표 · 더하기 · 빼기(이은 칸이면 연결도 풂 확인) · [적용] 모달
- 서버 길(branchCombo · branchMaterialCombo · 자재 조합 읽기)은 아직 안 올라와 M02_MasterTemplete_Combo_Api.ts 한 곳에 가정 모양으로 둠

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 18:51:03 +09:00
co-authored by Claude Sonnet 5
parent ff0d9775ec
commit 0c3b089719
5 changed files with 862 additions and 17 deletions
@@ -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;
}
@@ -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<string | null> {
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<boolean>;
/** 갈라 낸 뒤 — 알약 다시 받기 */
onApplied: () => Promise<void> | void;
}
interface Line {
로직: string;
메모: string | null;
이름: string;
결과단위: string;
}
/** 조합 줄 — 이 열에 일위대가가 없으면 null */
export async function buildComboBar(opts: ComboBarOptions): Promise<HTMLElement | null> {
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] }),
);
},
});
}
}
@@ -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<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 async function readColumnBase(column: string): Promise<ColumnBase> {
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<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);
@@ -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, string | number>): 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<void> => {
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<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");
}
}
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;
}
@@ -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<void> {
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<typeof createSpreadsheet>[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<void> {
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<typeof createSpreadsheet>[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,