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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user