Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2

This commit is contained in:
2026-10-04 20:48:51 +09:00
8 changed files with 10 additions and 770 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ export const STATE_REGISTRY = {
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
@@ -187,33 +187,6 @@ export interface StructureCombos {
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
export interface BranchedCombo {
키: string;
이름: string;
새로만듦: boolean;
집계표판: string;
}
/** 이 열이 가리키는 일위대가 조합을 고친 담은 로직으로 갈라 냄 — 새 UA · 집계표 그 열만 새 키 ·
* `판`(집계표 판)을 주면 어긋날 때 StaleError · 원래 조합은 그대로 */
export const branchCombo = (
column: string,
body: {
본뜬키: string;
담은로직: { 로직: string; 메모?: string | null }[];
이름: string;
판?: string;
},
): Promise<BranchedCombo> =>
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 키만 바꿈(조합은 그대로 · `새로만듦` false) */
export const setColumnCombo = (
column: string,
body: { 종류: "일위대가"; 키: string; 판?: string },
): Promise<BranchedCombo> =>
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
export interface OptionKind {
type_id: string;
@@ -1,75 +0,0 @@
/* 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;
}
@@ -1,309 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Combo.ts
* 면적산출 편집 화면 — 오른쪽 조합 패널 「이 열의 조합」 칸의 조합 줄 + [조합 고치기](시스템 관리자만).
* 일위대가 조합(UA)은 여러 열 · 모든 프로젝트가 같이 씀 → 제자리 고치지 않고 고친 내용으로 새 조합을 만들어
* 이 열만 가리킴(서버 `branchCombo`) · 원래 조합은 그대로. 담은 로직 표는 M01 조합 화면과 같은 줄 모양.
* ========================================================================== */
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchCombo, type ComboLine } from "./M02_MasterTemplete_Labor_Api";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch";
import { readColumnBase, reasonOf } 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: "고친 것이 없음",
discard: "고친 것을 버리고 닫음",
discardOk: "버리고 닫음",
nameEmpty: "이름을 넣음",
noLines: "담은 로직이 없어 적용 안 됨 — 로직을 하나 이상 담음",
nameLong: "이름은 {n}자까지",
loadFailed: "조합을 못 읽음 — {value}",
askTitle: "새 조합으로 갈라 냄",
askName: "새 조합 이름",
askOk: "새로 만듦",
askCancel: "취소",
askBody:
"이 조합은 열 {n} 개와 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로",
askAlone:
"이 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용의 조합이 있으면 그것을 씀 · 원래 조합은 그대로",
made: "새 {kind} {key} 「{name}」 을 만듦 — 이 열이 가리킴",
reused: "같은 내용의 {kind} {key} 「{name}」 을 씀 — 새로 안 만듦",
failed: "조합을 못 고침 — {value}",
};
/** 조합 이름 글자 수 한도 — 서버 `master_combo.MAX_이름` 과 같음 */
const MAX_NAME = 60;
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.slice(0, MAX_NAME);
name.input.maxLength = MAX_NAME;
const ok = createButton({
label: TEXT.askOk,
variant: "filled",
onClick: () => {
const value = name.input.value.trim();
if (!value || value.length > MAX_NAME) {
name.setError(value ? fill(TEXT.nameLong, MAX_NAME) : TEXT.nameEmpty);
return name.input.focus();
}
finish(value);
close();
},
});
name.input.addEventListener("input", () => name.setError(undefined));
name.input.addEventListener("keydown", (ev) => ev.key === "Enter" && ok.click());
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 });
});
}
/** 새 조합 이름 자동 — 원래 이름(끝 「 (n)」 뗌 · 서버가 새로 붙임) · 구조물 이름(한쪽이 다른 쪽을
* 이미 품으면 긴 쪽 하나만) */
export const branchName = (
named: string,
label: string,
original = named.replace(/ \(\d+\)$/, ""),
): string =>
!original || label.includes(original)
? label || original
: !label || original.includes(label)
? original
: `${original} · ${label}`;
/** 결과 알림 — 새로 만듦 / 같은 내용의 있는 조합을 씀(`kind` = 일위대가 조합 · 자재 조합) */
export const branchToast = (r: BranchedCombo, kind = "일위대가 조합"): void =>
showToast(
(r.새로만듦 ? TEXT.made : TEXT.reused)
.replace("{kind}", kind)
.replace("{key}", r.키)
.replace("{name}", 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 changed = (): boolean =>
JSON.stringify(lines.map((l) => [l.로직, l.메모 ?? null])) !== original;
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",
// 고친 채 닫기 · Esc · 바깥 누르기 — 버릴지 물음
beforeClose: () => !changed() || showConfirmDialog(TEXT.discard, TEXT.discardOk),
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.메모 ?? "", title: line.메모 ?? "" },
});
memo.addEventListener("input", () => {
line.메모 = memo.value || null;
memo.title = memo.value;
});
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 (!changed()) {
return showToast(TEXT.unchanged, "info");
}
if (!lines.length) return showToast(TEXT.noLines, "error");
const message = others ? fill(TEXT.askBody, others + 1) : TEXT.askAlone;
const name = await askBranch(message, branchName(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] }),
);
},
});
}
}
@@ -1,91 +0,0 @@
/* 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);
}
/* 고른 줄 + 바로 아래 [이 열에 쓰기] */
.m02-cpanel__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
}
.m02-cpanel__pick > .ui-btn {
align-self: flex-end;
}
/* 이 열이 쓰는 조합 — 테두리로 늘 보임(고름 is-active 와 따로) */
.m02-cpanel__row.is-current {
box-shadow: inset 3px 0 0 var(--color-primary);
}
@@ -1,227 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_ComboPanel.ts
* 면적산출 편집 화면 오른쪽 안쪽 패널 — 좌측 패널과 같은 공용 틀(사이드바 카드 · 접기) ·
* 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`).
* · 「이 열의 조합」 — 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) ·
* 넣는 쪽이 `setMine` 으로 갈아 끼움
* · 조합 목록 — 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) ·
* 찾기 · 이 열 조합 강조 ·
* 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `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 { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api";
import { getActiveLayer, 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: "일위대가 조합 목록",
search: "키 · 이름으로 찾기",
logics: "로직 {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";
/** 집계표 열 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 {
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(TEXT.listArea, find.root, list)],
});
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(TEXT.logics, 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 opts.isAdmin && r.키 === picked && r.키 !== current
? el("div", { className: "m02-cpanel__pick", children: [b, use] })
: b;
})
: [el("p", { className: "m02-side__empty", text: q ? TEXT.empty : TEXT.none })]),
);
use.disabled = false;
if (use.isConnected) use.scrollIntoView({ block: "nearest" });
}
async function refresh(): Promise<void> {
const mineSeq = ++seq;
try {
const [base, got] = await Promise.all([
readColumnBase(opts.column),
fetchUsableCombos(getActiveLayer()).then((cs) =>
cs.map((c) => ({
키: c.키,
이름: c.이름,
수: c.count, // 목록 요약엔 담은로직이 없음 — 수만 옴
본뜬키: c.본뜬키 ?? null,
})),
),
]);
if (mineSeq !== seq) return;
current = 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, {
종류: "일위대가",
키: 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();
},
};
}
@@ -1,35 +0,0 @@
/* =============================================================================
* M02_MasterTemplete_Combo_Api.ts
* 조합 고치기 화면이 쓰는 읽기 — 집계표 열이 가리키는 조합 키 · 같이 쓰는 다른 열 수 ·
* 서버 까닭 글. 갈라 내기 길(`branchCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
* ========================================================================== */
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */
const STRUCTURE_TABLE = "구조물집계표";
/** 집계표 열이 가리키는 조합 키 + 같은 조합을 쓰는 다른 열 수 */
export interface ColumnBase {
일위대가: string | null;
다른열일위대가: number;
}
/** 서버가 준 까닭 글 */
export const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 집계표(시스템 층)에서 이 열의 일위대가 키와 같이 쓰는 다른 열 수 */
export async function readColumnBase(column: string): Promise<ColumnBase> {
type Col = { id: string; 일위대가?: string | null };
const table = await readTemplate(getActiveLayer(), "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,
다른열일위대가: same(mine?.일위대가, (c) => c.일위대가),
};
}
+9 -5
View File
@@ -170,12 +170,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
)
def test_면적_재료_오른쪽_조합_패널은_공용_틀_손잡이():
"""좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음."""
cpanel = _text(M02 / "M02_MasterTemplete_ComboPanel.ts")
def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이():
"""좌측과 같은 사이드바 카드 · 접기 · 공용 폭 손잡이 · 접기 손잡이 — 새로 그리지 않음(39-4 · 옛 조합 패널 지움)."""
apanel = _text(M02 / "M02_MasterTemplete_AreaPanel.ts")
for name in ("createPanelResizer", "createWorkflowPanelHandle", "attachCollapsible"):
assert name in cpanel, name
assert "ui-sidebar-card" in cpanel and "setColumnCombo" in cpanel
assert name in apanel, name
assert "ui-sidebar-card" in apanel and "setColumnCombo" not in apanel
for gone in ("ComboPanel.ts", "ComboPanel.css", "Combo.ts", "Combo.css", "Combo_Api.ts"):
assert not (M02 / f"M02_MasterTemplete_{gone}").exists(), gone
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert "branchCombo" not in api and "setColumnCombo" not in api
main = _text(MAIN)
assert "comboSlot" not in main # 조합 줄은 패널 「이 열의 조합」 칸으로
assert "matPanel" not in main # 자재 조합 판은 없앰