feat(M02): 면적산출 상세 오른쪽 = 일위대가 패널 AreaPanel — 담은 로직 · 자재목록(기준 · 반영) · 줄 누름 칸 지정 · 칸 값 바뀌면 바로 곱함 · 조합 줄 · [조합 고치기] 지움 (PLAN 39-4 D2 · D6)

- AreaLink 칸 값 글(수 · 글 · 참 · 오류) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-04 19:06:21 +09:00
co-authored by Claude Opus 5.5
parent f0b8882a84
commit 611f5413f3
5 changed files with 267 additions and 20 deletions
@@ -8,7 +8,7 @@
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
const TEXT = {
title: "칸 지정 — {value}",
@@ -39,6 +39,10 @@ export interface AreaLinkOptions {
readOnly: boolean;
}
/** 칸 값 글 — 수 · 글 · 참거짓 · 오류 */
export const cellText = (v: CalcValue | undefined): string =>
!v ? "" : "수" in v ? v.수 : "글" in v ? v.글 : "참" in v ? String(v.참) : v.오류;
/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */
function linkedText(opts: AreaLinkOptions): string | null {
const link = opts.sheet
@@ -47,7 +51,7 @@ function linkedText(opts: AreaLinkOptions): string | null {
.find((l) => l.키 === opts.key);
if (!link) return null;
const value = opts.sheet.getLinkedValues()[opts.key];
return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim();
return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim();
}
export function openAreaLink(opts: AreaLinkOptions): void {
@@ -0,0 +1,64 @@
/* M02 면적산출 상세 오른쪽 패널(일위대가 담은 로직 · 자재목록) · 칸 지정 모달 — 틀은 공용 사이드바 카드 ·
접기 · 폭 손잡이 · 접기 손잡이 */
/* 편집 칸 = 시트 | 패널 — 패널 폭은 손잡이가 `--m02-apanel-width` 로 줌 */
.m02-apanel {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: column;
width: var(--m02-apanel-width, 36rem); /* 금액 칸 넷이 가로 굴림 없이 보이게 */
min-width: 280px;
max-width: 60%;
min-height: 0;
border-left: 1px solid var(--color-border);
}
.m02-apanel.is-resizing {
transition: none;
}
.m02-apanel.is-collapsed {
width: 0;
min-width: 0;
}
.m02-apanel.is-collapsed > .m02-apanel__body,
.m02-apanel.is-collapsed > .ui-resizer {
display: none;
}
/* 접기 손잡이 — 패널 왼쪽 밖 세로 가운데(시트 위로) */
.m02-apanel > .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-apanel__body {
flex: 1 1 0;
min-height: 0;
overflow: auto;
padding: var(--spacing-8);
}
/* 칸 지정 모달 — 지금 연결 한 줄 · [지금 고른 칸으로] [풀기] · 주소 칸 [잇기] */
.m02-area-link__now {
margin: 0 0 var(--spacing-12);
color: var(--color-text-secondary);
}
.m02-area-link__row {
display: flex;
align-items: flex-end;
gap: var(--spacing-8);
margin-bottom: var(--spacing-12);
}
.m02-area-link__row > .ui-field {
flex: 1 1 auto;
margin: 0;
}
@@ -0,0 +1,125 @@
/* =============================================================================
* M02_MasterTemplete_AreaPanel.ts
* 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6) — 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영).
* 몸 = 일위대가 보기 갈래(`Labor_View` · 고치기 없음) · 틀 = 공용 사이드바 카드 · 접기 손잡이 · 폭 손잡이
* (폭 · 접힘 기억은 옛 조합 패널 키 그대로).
* · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기)
* · 시트 칸 값이 바뀌면 `refresh` — 받은 기준에 바로 곱함(서버 안 부름 · `Labor_View.setQty`)
* ========================================================================== */
import { el, 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 type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { openAreaLink } from "./M02_MasterTemplete_AreaLink";
import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View";
import "./M02_MasterTemplete_AreaPanel.css";
const TEXT = {
handle: "일위대가",
title: "이 열의 일위대가",
noPill: "이 줄은 면적산출 알약이 없음 — 돈 아닌 로직이거나 계산 전",
};
/** 최소 폭(px) · 최대 = 편집 칸 폭의 비율 */
const MIN_WIDTH = 280;
const MAX_RATIO = 0.6;
export interface AreaPanelOptions {
layer: Layer;
/** 집계표 열 id */
column: string;
sheet: () => SpreadsheetHandle | null;
/** 시트에서 지금 고른 칸(A1) */
picked: () => string;
/** 알약 막대 항목 — 칸 지정 때 연결에 같이 적을 이름 · 단위 · 자재 */
pills: () => LinkItem[];
readOnly: boolean;
}
export interface AreaPanel {
root: HTMLElement;
/** 시트 칸 값 · 연결이 바뀜 — 반영을 바로 다시 곱함 */
refresh: () => void;
destroy: () => void;
}
/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */
function quantities(sheet: SpreadsheetHandle | null): Record<string, number> {
const out: Record<string, number> = {};
for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) {
const n = "수" in v ? Number(v.수) : NaN;
if (Number.isFinite(n)) out[key] = n;
}
return out;
}
export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
const openLink = (row: LaborViewRow): void => {
const sheet = opts.sheet();
if (!sheet) return;
if (!row.pill) return showToast(TEXT.noPill, "info");
const item = opts.pills().find((p) => p.키 === row.pill);
openAreaLink({
label: row.name,
key: row.pill,
info: {
이름: item?.이름 ?? row.name,
...((item?.단위 ?? row.unit) ? { 단위: item?.단위 ?? row.unit } : {}),
...(item?.자재 ? { 자재: item.자재 } : {}),
},
sheet,
picked: opts.picked,
readOnly: opts.readOnly,
});
};
const view = createLaborView(openLink);
const body = el("div", {
className: "m02-apanel__body ui-sidebar-panel",
children: [
el("section", {
className: "ui-collapsible ui-sidebar-section ui-sidebar-card",
children: [el("h3", { className: "ui-collapsible__title", text: TEXT.title }), view.root],
}),
],
});
attachCollapsible(body);
const root = el("aside", { className: "m02-apanel" });
const handle = createWorkflowPanelHandle("side", "right", TEXT.handle);
// 왼쪽 경계를 끌어 폭 조절 — 폭은 브라우저에 기억 · 더블클릭 = 기본 폭
const resizer = createPanelResizer({
axis: "horizontal",
target: root,
cssVar: "--m02-apanel-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");
// 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번)
void view.show(opts.layer, opts.column, quantities(opts.sheet()));
return {
root,
refresh: () => view.setQty(quantities(opts.sheet())),
destroy: () => {
view.destroy();
resizer.dispose();
root.remove();
},
};
}
@@ -28,7 +28,7 @@ import {
} from "./M02_MasterTemplete_Structure";
import { inputPills } from "./M02_MasterTemplete_Inputs";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildComboBar } from "./M02_MasterTemplete_Combo";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
import {
buildMaterialPanel,
@@ -193,7 +193,9 @@ export function createMain(
let sheetHandle: SpreadsheetHandle | null = null;
let matPanel: MaterialPanel | null = null;
let matState: MaterialState | null = null;
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
let cpanel: ComboPanel | null = null; // 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
let apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4)
let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로]
let seq = 0;
let dirty = false;
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */
@@ -242,6 +244,8 @@ export function createMain(
matState = null;
cpanel?.destroy();
cpanel = null;
apanel?.destroy();
apanel = null;
panel?.destroy();
panel = null;
extra.replaceChildren();
@@ -264,8 +268,8 @@ export function createMain(
return made;
}
/** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 ·
* 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] ·
/** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) ·
* 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) ·
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
async function mountSheet(
@@ -305,13 +309,29 @@ export function createMain(
onChange: () => {
dirty = !readOnly;
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
},
onSelect: (s) => (picked = s.칸),
links,
},
);
editor = handle;
sheetHandle = handle;
if (at.layer === "project") return;
if (kind === "area") {
// 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달
apanel?.destroy();
apanel = buildAreaPanel({
layer: at.layer,
column: at.name,
sheet: () => sheetHandle,
picked: () => picked,
pills: () => links.항목,
readOnly,
});
host.append(apanel.root);
return;
}
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
cpanel ??= buildComboPanel({
kind,
@@ -320,20 +340,6 @@ export function createMain(
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 () => {
await remount(kind, at, readOnly, false);
await cpanel?.refresh();
},
});
if (mine === seq) cpanel?.setMine(bar);
return;
}
if (matPanel || !matState) return;
matPanel = buildMaterialPanel({
column: at.name,
+48
View File
@@ -0,0 +1,48 @@
"""면적산출 상세 오른쪽 일위대가 패널 · 칸 지정 모달(PLAN 39-4 D2 · D5 · D6) — 화면 조작은 ORCA.
· 패널 = 그 열 일위대가 보기 갈래(`Labor_View`) · 조합 목록 · 찾기 · [이 열에 쓰기] · [조합 고치기] 없음
· 줄 누름 = 칸 지정 모달 → 스프레드시트 `setLink`(면적 문서 `연결` 한 곳 · 알약과 같은 길)
· 시트 칸 값 · 연결이 바뀌면 `setQty` 로 바로 곱함(서버 안 부름)
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def test_면적_길은_일위대가_패널():
main = _text("M02_MasterTemplete_UI_Main.ts")
body = main.split("async function mountSheet")[1].split("async function remount")[0]
area = body.split('if (kind === "area") {')[1].split("\n }\n")[0]
assert "apanel = buildAreaPanel({" in area and "host.append(apanel.root);" in area
assert "picked: () => picked," in area and "pills: () => links.항목," in area
assert "apanel?.refresh();" in body and "onSelect: (s) => (picked = s.칸)," in body
assert "buildComboBar" not in main # 조합 줄 · [조합 고치기] 지움
assert body.index('if (at.layer === "project") return;') < body.index("buildAreaPanel")
assert "apanel?.destroy();\n apanel = null;" in main # drop
def test_패널_몸은_보기_갈래_줄_누름은_칸_지정():
panel = _text("M02_MasterTemplete_AreaPanel.ts")
assert "createLaborView(openLink)" in panel
assert "void view.show(opts.layer, opts.column, quantities(opts.sheet()));" in panel
assert "refresh: () => view.setQty(quantities(opts.sheet()))," in panel
assert "openAreaLink({" in panel and "if (!row.pill) return showToast(TEXT.noPill" in panel
for gone in ("fetchUsableCombos", "setColumnCombo", "branchCombo"):
assert gone not in panel
def test_칸_지정_모달은_setLink_만():
link = _text("M02_MasterTemplete_AreaLink.ts")
assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link
assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
assert "saveTemplate" not in link # 저장은 [저장]