Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
242 lines
9.0 KiB
TypeScript
242 lines
9.0 KiB
TypeScript
/* =============================================================================
|
|
* 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),
|
|
],
|
|
});
|
|
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 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),
|
|
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();
|
|
},
|
|
};
|
|
}
|