feat(M02): 면적산출 · 재료산출 오른쪽 조합 패널 — 조합 목록 · 이 열에 쓰기 · 조합 고치기
- 오른쪽 안쪽 패널: 공용 사이드바 카드 · 접기(attachCollapsible) · 공용 폭 손잡이(createPanelResizer · 폭 기억 · 최소 280 · 최대 60%) · 공용 접기 손잡이(createWorkflowPanelHandle)
- 「이 열의 조합」 칸: 면적 = 일위대가 조합 줄 + [조합 고치기] · 재료 = 담은 자재 표(옆 판 aside 없앰)
- 조합 목록: 면적 = 일위대가(UA) · 재료 = 자재 조합(UM) · 키 · 이름 · 수 · 본뜬키 · 찾기 · 이 열 조합 강조
- [이 열에 쓰기]: 서버 POST /api/m02/structures/{column}/use-combo(집계표 판 검사 · 그 열 키만 바꿈 · 조합은 그대로) → 알약 다시 받음
- 상태 등록표에 m02-combo-panel-width · collapsed · 시험 더함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
This commit is contained in:
@@ -57,6 +57,9 @@ export const STATE_REGISTRY = {
|
||||
legacy: () => "b05-route-drainage-collapsed",
|
||||
},
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -184,6 +184,13 @@ export const branchCombo = (
|
||||
): Promise<BranchedCombo> =>
|
||||
call(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */
|
||||
export const setColumnCombo = (
|
||||
column: string,
|
||||
body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string },
|
||||
): Promise<BranchedCombo> =>
|
||||
call(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/** 자재 조합(키 `UM`) 담은 자재 한 줄 */
|
||||
export interface MaterialItem {
|
||||
코드: string;
|
||||
|
||||
@@ -49,12 +49,7 @@ def _branch(
|
||||
) -> dict[str, Any]:
|
||||
"""집계표 판 검사 → 같은 내용 찾기 · 없으면 `make(이름, 줄들)` 로 새 줄 → 집계표 그 열 `field`.
|
||||
`slot` = 담은 칸(담은로직 · 담은자재) · `ident` = 담은 줄에서 견주는 칸(로직 · 메모 / 코드)."""
|
||||
table = store.read("table", store.STRUCTURE_TABLE)
|
||||
if version and table["판"] != version:
|
||||
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]})
|
||||
col = next((c for c in store._structure_columns() if c["id"] == column), None)
|
||||
if col is None:
|
||||
raise store.StoreError(404, f"{store.STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
|
||||
table = _table(column, version)
|
||||
if not isinstance(items, list) or not items:
|
||||
raise store.StoreError(400, "담은 것이 비었음")
|
||||
|
||||
@@ -72,12 +67,39 @@ def _branch(
|
||||
key, made = make(made_name, rows), True
|
||||
# ponytail: 조합 쓰기와 집계표 쓰기는 한 묶음 아님 — 사이에 집계표가 바뀌면 409 뒤 주인 없는
|
||||
# 조합 하나가 남음(다시 하면 같은 내용이라 그 키를 씀)
|
||||
판 = _point(table, column, field, key)
|
||||
return {"키": key, "이름": made_name, "새로만듦": made, "집계표판": 판}
|
||||
|
||||
|
||||
def _table(column: str, version: str) -> dict:
|
||||
"""집계표 판 검사 · 구조물 열 있는지 — 읽은 표(`{판, 문서}`)."""
|
||||
table = store.read("table", store.STRUCTURE_TABLE)
|
||||
if version and table["판"] != version:
|
||||
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": table["판"]})
|
||||
if not any(c["id"] == column for c in store._structure_columns()):
|
||||
raise store.StoreError(404, f"{store.STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
|
||||
return table
|
||||
|
||||
|
||||
def _point(table: dict, column: str, field: str, key: str) -> str:
|
||||
"""집계표 그 열 `field` = `key` 로 저장 — 새 판."""
|
||||
doc = table["문서"]
|
||||
for c in doc.get("열", []):
|
||||
if isinstance(c, dict) and c.get("id") == column:
|
||||
c[field] = key
|
||||
info = store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # SS 도 여기서 맞춤
|
||||
return {"키": key, "이름": made_name, "새로만듦": made, "집계표판": info["판"]}
|
||||
return store.write("table", store.STRUCTURE_TABLE, table["판"], doc)["판"] # SS 도 여기서 맞춤
|
||||
|
||||
|
||||
def use_combo(column: str, field: str, key: str, version: str = "") -> dict[str, Any]:
|
||||
"""있는 조합을 이 열에 씀 — `field` 일위대가(UA) · 자재조합(UM) · 조합은 그대로(고르기만)."""
|
||||
if field == "일위대가":
|
||||
name = _m01(combo_store.combo, key)["combo"].get("이름")
|
||||
elif field == "자재조합":
|
||||
name = material_combo(key).get("이름")
|
||||
else:
|
||||
raise store.StoreError(400, f"모르는 조합 종류 「{field}」")
|
||||
판 = _point(_table(column, version), column, field, key)
|
||||
return {"키": key, "이름": str(name or ""), "새로만듦": False, "집계표판": 판}
|
||||
|
||||
|
||||
def branch_combo(
|
||||
|
||||
@@ -74,42 +74,19 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 재료산출 — 엑셀 옆 접히는 판 */
|
||||
.m02-main__host:has(> .ss) > .m02-mat {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 재료산출 — 오른쪽 조합 패널 「이 열의 조합」 칸: 담은 자재 표 */
|
||||
.m02-mat {
|
||||
order: 2;
|
||||
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 .m02-mat__row {
|
||||
grid-template-columns: 5.5rem minmax(0, 1.4fr) minmax(0, 1fr) 2.5rem 3rem auto;
|
||||
}
|
||||
|
||||
.m02-mat__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -138,7 +115,3 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-main__combo:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Combo.ts
|
||||
* 면적산출 편집 화면 — 알약 막대 위 조합 줄 + [조합 고치기](시스템 관리자만).
|
||||
* 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만).
|
||||
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
|
||||
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
|
||||
* 갈라 내기 모달(`askBranch`)은 재료산출 자재 목록(`M02_MasterTemplete_Materials.ts`)도 씀.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* M02 면적산출 · 재료산출 오른쪽 조합 패널 — 틀은 공용 사이드바 카드 · 접기 · 폭 손잡이 · 접기 손잡이 */
|
||||
|
||||
/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-cpanel-width` 로 줌 */
|
||||
.m02-cpanel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
width: var(--m02-cpanel-width, 24rem);
|
||||
min-width: 280px;
|
||||
max-width: 60%;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-cpanel.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.m02-cpanel.is-collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-cpanel.is-collapsed > .m02-cpanel__body,
|
||||
.m02-cpanel.is-collapsed > .ui-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */
|
||||
.m02-cpanel > .ui-workflow-overlay__handle--side {
|
||||
z-index: 6;
|
||||
right: auto;
|
||||
left: calc(-1 * var(--spacing-24));
|
||||
border-right: 0;
|
||||
border-radius: var(--radius-buttons) 0 0 var(--radius-buttons);
|
||||
}
|
||||
|
||||
/* 안쪽만 굴림 — 페이지 스크롤 안 생김 */
|
||||
.m02-cpanel__body {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-cpanel__list {
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-cpanel__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0 var(--spacing-8);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.m02-cpanel__key {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-cpanel__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-cpanel__meta {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 이 열이 쓰는 조합 — 테두리로 늘 보임(고름 is-active 와 따로) */
|
||||
.m02-cpanel__row.is-current {
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_ComboPanel.ts
|
||||
* 면적산출 · 재료산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
|
||||
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
|
||||
* · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
|
||||
* 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움
|
||||
* · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 ·
|
||||
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
|
||||
import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
import "./M02_MasterTemplete_ComboPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
handle: "조합",
|
||||
mine: "이 열의 조합",
|
||||
noneArea: "이 열에 일위대가 조합 없음",
|
||||
listArea: "일위대가 조합 목록",
|
||||
listMaterial: "자재 조합 목록",
|
||||
search: "키 · 이름으로 찾기",
|
||||
logics: "로직 {n}",
|
||||
items: "자재 {n}",
|
||||
origin: "본뜬 {value}",
|
||||
current: "이 열",
|
||||
empty: "맞는 조합 없음",
|
||||
none: "조합 없음",
|
||||
use: "이 열에 쓰기",
|
||||
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로",
|
||||
useOk: "바꿈",
|
||||
used: "이 열이 {value} 을 씀",
|
||||
loadFailed: "조합 목록을 못 받음 — {value}",
|
||||
failed: "이 열에 못 씀 — {value}",
|
||||
};
|
||||
|
||||
const fill = (text: string, value: string | number): string =>
|
||||
text.replace("{n}", String(value)).replace("{value}", String(value));
|
||||
|
||||
/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_RATIO = 0.6;
|
||||
|
||||
interface Row {
|
||||
키: string;
|
||||
이름: string;
|
||||
수: number;
|
||||
본뜬키: string | null;
|
||||
}
|
||||
|
||||
export interface ComboPanelOptions {
|
||||
kind: "area" | "material";
|
||||
/** 집계표 열 id */
|
||||
column: string;
|
||||
isAdmin: boolean;
|
||||
/** [이 열에 쓰기] 뒤 — 알약 · 「이 열의 조합」을 새 조합으로 다시 그림 */
|
||||
onUsed: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComboPanel {
|
||||
root: HTMLElement;
|
||||
/** 「이 열의 조합」 부분을 갈아 끼움(면적 = 조합 줄 · 재료 = 담은 자재 표) */
|
||||
setMine: (node: HTMLElement | null) => void;
|
||||
/** 목록 · 이 열 조합 키를 다시 받음(갈라 낸 뒤 등) */
|
||||
refresh: () => Promise<void>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function buildComboPanel(opts: ComboPanelOptions): ComboPanel {
|
||||
const area = opts.kind === "area";
|
||||
let rows: Row[] = [];
|
||||
let current: string | null = null;
|
||||
let picked: string | null = null;
|
||||
let q = "";
|
||||
let seq = 0;
|
||||
|
||||
const card = (title: string, ...children: HTMLElement[]): HTMLElement =>
|
||||
el("section", {
|
||||
className: "ui-collapsible ui-sidebar-section ui-sidebar-card",
|
||||
children: [el("h3", { className: "ui-collapsible__title", text: title }), ...children],
|
||||
});
|
||||
const mine = el("div", { className: "m02-cpanel__mine" });
|
||||
const find = createInputField({ type: "search", placeholder: TEXT.search });
|
||||
const list = el("div", { className: "ui-sidebar-list m02-cpanel__list" });
|
||||
const use = createButton({ label: TEXT.use, variant: "filled", size: "sm" });
|
||||
const body = el("div", {
|
||||
className: "m02-cpanel__body ui-sidebar-panel",
|
||||
children: [
|
||||
card(TEXT.mine, mine),
|
||||
card(
|
||||
area ? TEXT.listArea : TEXT.listMaterial,
|
||||
find.root,
|
||||
list,
|
||||
...(opts.isAdmin ? [el("div", { className: "m02-side__row", children: [use] })] : []),
|
||||
),
|
||||
],
|
||||
});
|
||||
attachCollapsible(body);
|
||||
|
||||
const root = el("aside", { className: "m02-cpanel", attrs: { "data-kind": opts.kind } });
|
||||
const handle = createWorkflowPanelHandle("side", "right", TEXT.handle);
|
||||
// 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭
|
||||
const resizer = createPanelResizer({
|
||||
axis: "horizontal",
|
||||
target: root,
|
||||
cssVar: "--m02-cpanel-width",
|
||||
direction: -1,
|
||||
min: MIN_WIDTH,
|
||||
max: () => (root.parentElement?.clientWidth ?? window.innerWidth) * MAX_RATIO,
|
||||
storageKey: stateKey("m02-combo-panel-width") ?? undefined,
|
||||
});
|
||||
root.append(handle.root, resizer.root, body);
|
||||
const setCollapsed = (collapsed: boolean): void => {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
handle.setOpen(!collapsed);
|
||||
writeStateRaw("m02-combo-panel-collapsed", String(collapsed));
|
||||
};
|
||||
handle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
setCollapsed(readStateRaw("m02-combo-panel-collapsed") === "true");
|
||||
|
||||
find.input.addEventListener("input", () => {
|
||||
q = find.input.value.trim().toLowerCase();
|
||||
paint();
|
||||
});
|
||||
|
||||
function paint(): void {
|
||||
const shown = rows.filter((r) => !q || `${r.키} ${r.이름}`.toLowerCase().includes(q));
|
||||
list.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map((r) => {
|
||||
const meta = [
|
||||
fill(area ? TEXT.logics : TEXT.items, r.수),
|
||||
r.본뜬키 ? fill(TEXT.origin, r.본뜬키) : "",
|
||||
r.키 === current ? TEXT.current : "",
|
||||
];
|
||||
const b = el("button", {
|
||||
className:
|
||||
"m02-side__item ui-sidebar-row m02-cpanel__row" +
|
||||
(r.키 === picked ? " is-active" : "") +
|
||||
(r.키 === current ? " is-current" : ""),
|
||||
attrs: { type: "button", "data-combo-key": r.키, title: `${r.키} ${r.이름}` },
|
||||
children: [
|
||||
el("span", { className: "m02-cpanel__key", text: r.키 }),
|
||||
el("span", { className: "m02-cpanel__name", text: r.이름 }),
|
||||
el("span", {
|
||||
className: "m02-cpanel__meta",
|
||||
text: meta.filter(Boolean).join(" · "),
|
||||
}),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
picked = r.키 === picked ? null : r.키;
|
||||
paint();
|
||||
});
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]),
|
||||
);
|
||||
use.disabled = !picked || picked === current;
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mineSeq = ++seq;
|
||||
try {
|
||||
const [base, got] = await Promise.all([
|
||||
readColumnBase(opts.column),
|
||||
area
|
||||
? fetchCombos().then((cs) =>
|
||||
cs.map((c) => ({
|
||||
키: c.키,
|
||||
이름: c.이름,
|
||||
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
|
||||
본뜬키: c.본뜬키 ?? null,
|
||||
})),
|
||||
)
|
||||
: listMaterialCombos().then((d) =>
|
||||
d.줄.map((m) => ({
|
||||
키: m.키,
|
||||
이름: m.이름,
|
||||
수: m.담은자재.length,
|
||||
본뜬키: m.본뜬키,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
if (mineSeq !== seq) return;
|
||||
current = (area ? base.일위대가 : base.자재조합) ?? null;
|
||||
rows = got;
|
||||
} catch (error) {
|
||||
if (mineSeq !== seq) return;
|
||||
showToast(fill(TEXT.loadFailed, reasonOf(error)), "error");
|
||||
}
|
||||
if (picked === current) picked = null;
|
||||
paint();
|
||||
}
|
||||
|
||||
use.addEventListener("click", async () => {
|
||||
const row = rows.find((r) => r.키 === picked);
|
||||
if (!row || row.키 === current) return;
|
||||
if (!(await showConfirmDialog(fill(TEXT.useAsk, `${row.키} ${row.이름}`), TEXT.useOk))) return;
|
||||
use.disabled = true;
|
||||
try {
|
||||
const got = await setColumnCombo(opts.column, {
|
||||
종류: area ? "일위대가" : "자재조합",
|
||||
키: row.키,
|
||||
});
|
||||
showToast(fill(TEXT.used, `${got.키} ${got.이름}`), "success");
|
||||
picked = null;
|
||||
await opts.onUsed();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.failed, reasonOf(error)), "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
paint();
|
||||
void refresh();
|
||||
return {
|
||||
root,
|
||||
setMine: (node) =>
|
||||
mine.replaceChildren(node ?? el("p", { className: "m02-side__empty", text: TEXT.noneArea })),
|
||||
refresh,
|
||||
destroy: () => {
|
||||
++seq;
|
||||
resizer.dispose();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo";
|
||||
import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api";
|
||||
|
||||
const TEXT = {
|
||||
toggle: "자재 목록",
|
||||
combo: "자재 조합",
|
||||
branched: "본뜬 조합 {value}",
|
||||
none: "자재 조합 없음 — 자재를 더하면 새로 만듦",
|
||||
@@ -119,6 +118,8 @@ export interface MaterialPanelOptions {
|
||||
links: () => CellLink[];
|
||||
/** 자재 코드에 이어진 칸 연결을 풂(시트 문서를 고치고 고침 표시) */
|
||||
unlink: (codes: string[]) => void;
|
||||
/** [적용]으로 이 열이 새 · 다른 자재 조합을 가리킴 — 조합 목록 다시 받기 */
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */
|
||||
@@ -181,21 +182,11 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
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 root = el("div", { className: "m02-mat", children: [title, table, foot] });
|
||||
|
||||
const cellsOf = (code: string): string[] =>
|
||||
opts
|
||||
@@ -257,7 +248,6 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
...(rows.length ? rows : [el("p", { className: "m02-struct__empty", text: TEXT.empty })]),
|
||||
);
|
||||
paintFoot();
|
||||
paintToggle();
|
||||
};
|
||||
|
||||
const paintFoot = (): void => {
|
||||
@@ -298,6 +288,7 @@ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel {
|
||||
saved = combo?.담은자재 ?? [...items];
|
||||
items = [...saved];
|
||||
paint();
|
||||
opts.onApplied?.();
|
||||
} catch (error) {
|
||||
apply.disabled = false;
|
||||
showToast(fill(TEXT.failed, { value: reasonOf(error) }), "error");
|
||||
|
||||
@@ -84,6 +84,18 @@ def post_structure_material_combo(column: str, body: MaterialBranchBody) -> dict
|
||||
)
|
||||
|
||||
|
||||
class UseComboBody(BaseModel):
|
||||
종류: str # 일위대가 · 자재조합
|
||||
키: str
|
||||
판: str = "" # 집계표 판 — 주면 어긋날 때 409
|
||||
|
||||
|
||||
@router.post("/structures/{column}/use-combo")
|
||||
def post_structure_use_combo(column: str, body: UseComboBody) -> dict:
|
||||
"""있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로)."""
|
||||
return _call(branch.use_combo, column, body.종류, body.키, body.판)
|
||||
|
||||
|
||||
# 자재 조합 표(마스터 템플릿 `자재조합.json` · 키 UM) — 좌측 「자재 조합」 컨테이너
|
||||
@router.get("/material-combos")
|
||||
def get_material_combos() -> dict:
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type StructurePanelHandle,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildComboBar } from "./M02_MasterTemplete_Combo";
|
||||
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
|
||||
import {
|
||||
buildMaterialPanel,
|
||||
loadMaterialState,
|
||||
@@ -109,7 +110,6 @@ export function createMain(
|
||||
const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
|
||||
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", {
|
||||
@@ -117,7 +117,6 @@ export function createMain(
|
||||
children: [
|
||||
el("div", { className: "m02-main__head", children: [title, back, slot, reload, save] }),
|
||||
notice,
|
||||
comboSlot,
|
||||
host,
|
||||
extra,
|
||||
],
|
||||
@@ -132,6 +131,7 @@ export function createMain(
|
||||
let sheetHandle: SpreadsheetHandle | null = null;
|
||||
let matPanel: MaterialPanel | null = null;
|
||||
let matState: MaterialState | null = null;
|
||||
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(시스템 층)
|
||||
let seq = 0;
|
||||
let dirty = false;
|
||||
|
||||
@@ -176,11 +176,12 @@ export function createMain(
|
||||
sheetHandle = null;
|
||||
matPanel = null;
|
||||
matState = null;
|
||||
cpanel?.destroy();
|
||||
cpanel = null;
|
||||
panel?.destroy();
|
||||
panel = null;
|
||||
extra.replaceChildren();
|
||||
slot.replaceChildren();
|
||||
comboSlot.replaceChildren();
|
||||
};
|
||||
|
||||
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
|
||||
@@ -244,23 +245,26 @@ export function createMain(
|
||||
editor = handle;
|
||||
sheetHandle = handle;
|
||||
if (at.layer !== "system") return;
|
||||
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
|
||||
cpanel ??= buildComboPanel({
|
||||
kind,
|
||||
column: at.name,
|
||||
isAdmin,
|
||||
onUsed: () => remount(kind, at, readOnly, true),
|
||||
});
|
||||
host.append(cpanel.root);
|
||||
if (kind === "area") {
|
||||
const bar = await buildComboBar({
|
||||
column: at.name,
|
||||
label: at.label ?? at.name,
|
||||
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);
|
||||
await remount(kind, at, readOnly, false);
|
||||
await cpanel?.refresh();
|
||||
},
|
||||
});
|
||||
if (mine === seq) comboSlot.replaceChildren(...(bar ? [bar] : []));
|
||||
if (mine === seq) cpanel?.setMine(bar);
|
||||
return;
|
||||
}
|
||||
if (matPanel || !matState) return;
|
||||
@@ -269,6 +273,7 @@ export function createMain(
|
||||
label: at.label ?? at.name,
|
||||
isAdmin,
|
||||
state: matState,
|
||||
onApplied: () => void cpanel?.refresh(),
|
||||
links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [],
|
||||
// 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
|
||||
onChange: (items) => {
|
||||
@@ -294,12 +299,31 @@ export function createMain(
|
||||
dirty = !readOnly;
|
||||
},
|
||||
});
|
||||
host.append(matPanel.root);
|
||||
cpanel.setMine(matPanel.root);
|
||||
} catch {
|
||||
empty(L("M02_NoSpreadsheet"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 시트만 다시 붙임(알약을 새로 받음) — 저장 안 한 시트 고침은 그대로 옮김 ·
|
||||
* `fresh` = 이 열 조합이 바뀜(재료는 자재 조합 · 담은 자재 표를 새로 받음) */
|
||||
async function remount(
|
||||
kind: "area" | "material",
|
||||
at: Selection,
|
||||
readOnly: boolean,
|
||||
fresh: boolean,
|
||||
): Promise<void> {
|
||||
const kept = sheetHandle?.getDoc();
|
||||
if (sel !== at || !kept) return;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
if (fresh && kind === "material") {
|
||||
matPanel = null;
|
||||
matState = null;
|
||||
}
|
||||
await mountSheet(kept, kind, at, readOnly);
|
||||
}
|
||||
|
||||
async function mount(doc: unknown, at: Selection): Promise<void> {
|
||||
const readOnly = !canEdit(at.layer, isAdmin);
|
||||
host.replaceChildren();
|
||||
|
||||
@@ -185,6 +185,34 @@ def test_자재_조합_처음_만듦_같은_내용이면_있는_키_고치면_
|
||||
_material(client, "rv", "", [{"코드": "없는모양"}], want=422) # 자재 조합 저장 검사
|
||||
|
||||
|
||||
def _use(client, column, kind, key, 판="", want=200):
|
||||
body = {"종류": kind, "키": key, "판": 판}
|
||||
res = client.post(f"/api/m02/structures/{column}/use-combo", json=body)
|
||||
assert res.status_code == want, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def test_있는_조합을_이_열에_씀_조합은_그대로(client):
|
||||
ua = _source(client)
|
||||
other = _branch(client, "rv", ua, [A, C], "다른")
|
||||
count = len(m01.read(combo_store.FILE)[0]["줄"])
|
||||
판 = client.get(TABLE).json()["판"]
|
||||
got = _use(client, "c1", "일위대가", other["키"], 판)
|
||||
assert (got["키"], got["이름"], got["새로만듦"]) == (other["키"], "다른", False)
|
||||
assert got["집계표판"] == client.get(TABLE).json()["판"] != 판
|
||||
assert _cols(client)["c1"] == other["키"] and _ss(client)["c1"] == other["키"]
|
||||
assert len(m01.read(combo_store.FILE)[0]["줄"]) == count # 새로 안 만듦
|
||||
_use(client, "c1", "일위대가", ua, 판, want=409) # 판 어긋남 — 안 바뀜
|
||||
assert _cols(client)["c1"] == other["키"]
|
||||
_use(client, "c1", "일위대가", "UA999999", want=404)
|
||||
_use(client, "no", "일위대가", ua, want=404) # 글 열
|
||||
_use(client, "c1", "모름", ua, want=400)
|
||||
um = _material(client, "rv", "", [M1, M2])["키"]
|
||||
_use(client, "c1", "자재조합", um)
|
||||
assert _cols(client, "자재조합")["c1"] == um and _ss(client, "자재조합")["c1"] == um
|
||||
_use(client, "c1", "자재조합", "UM999999", want=404)
|
||||
|
||||
|
||||
def test_분류_더하기로_복사한_열은_원본_키_세_파일_내용을_받음(client):
|
||||
ua = _source(client)
|
||||
table = client.get(TABLE).json()
|
||||
|
||||
@@ -154,6 +154,18 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
|
||||
)
|
||||
|
||||
|
||||
def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이():
|
||||
"""좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음."""
|
||||
cpanel = _text(M02 / "M02_MasterTemplete_ComboPanel.ts")
|
||||
for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"):
|
||||
assert name in cpanel, name
|
||||
assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel
|
||||
main = _text(MAIN)
|
||||
assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로
|
||||
assert "cpanel.setMine(matPanel.root)" in main # 담은 자재 표도 패널 안
|
||||
assert '"aside"' not in _text(M02 / "M02_MasterTemplete_Materials.ts")
|
||||
|
||||
|
||||
def test_첫_화면과_닫은_뒤는_구조물집계표():
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert re.search(r'kind: "table",\s*name: STRUCTURE_TABLE', page)
|
||||
|
||||
Reference in New Issue
Block a user