feat(규격 표 화면): 격자 규격 열 · M02 규격 표 편집 · 고르기 창 열 단추 (PLAN 704-4)

- 공용 격자 — 첫 호표 specs 면 수량 칸을 열마다(머리 「H 열」 · 기본 표시) · 합계 줄 그 칸 = 열마다 1 단위 합계
- M02 일위대가 카드 · 상세 — 격자 위 규격 줄(이름 · 열 이름 · 기본 · 열 지움 · [+ 열]) · 격자 수량 칸 열마다 고침(짝 셈 바로)
- 새 파일 M02_MasterTemplete_Labor_Spec.ts — 상세(700 줄)는 한 줄만 바꿈
- 고르기 창 — 규격 열 둘 이상이면 열 단추(열마다 합계) · [적용] = spec 붙은 참조 · 이름 뒤 「(H=열)」 · 열 하나 = 단추 없음
- 시험 704-3 격자 시험 두 줄 고침(laborGrid 넷째 인자 · Labor_Spec 이어 붙임) · 704-4 화면 자료 시험 둘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:23:46 +09:00
co-authored by Claude Opus 5.5
parent 425a4a9dfb
commit 56e2c0b515
9 changed files with 377 additions and 12 deletions
@@ -11,7 +11,7 @@ import {
type LogicSummary,
type SubBrief,
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborCalc, LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import type { LaborCalc, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
import {
call,
getActiveLayer,
@@ -238,6 +238,8 @@ export interface LaborDoc {
담은로직: LaborRow[];
자재: LaborMat[];
계산값?: unknown;
/** 규격 열(704-4 · `Labor_Spec`) — 없으면 옛 문서(열 하나) */
규격?: LaborSpec | null;
}
/** 받은 문서를 화면 모양으로 — 빈 칸 · 옛 모양도 받음(빈 일위대가) */
export function normalizeLabor(doc: unknown, column: string): LaborDoc {
@@ -13,7 +13,7 @@
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
* ========================================================================== */
import {
@@ -218,7 +218,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics));
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && !lib && changed));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -6,6 +6,7 @@
* 로직 줄 = 이름(별칭 앞섬) · 규격(옵션 값 · 켜쌓기) · 수량 · 분모 · 단가 = 기준 ÷ 배 · 금액 = 반영 · 꼬리표 품셈
* 자재 줄 = 이름 · 규격 · 수량 · 단위 · 재료비 단가 = 기준단가 · 금액 = 반영 · 별도계상은 비고 「합계 밖」
* 빈 줄(금액 모름 · 수량 없음) = 합계 줄 「빈 줄 n」 붉게 — 701-4 경고와 같은 줄
* 규격 열(704-4 · `Labor_Spec`) = 수량 칸 열마다 · 고칠 때 격자 위 규격 줄 + 수량 칸 고침
* ========================================================================== */
import {
@@ -20,6 +21,7 @@ import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { valueLine } from "./M02_Labor_Values_Model";
import { specBar, specCells, withSpecs, type SpecEdit } from "./M02_MasterTemplete_Labor_Spec";
const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
m
@@ -77,7 +79,7 @@ export function laborBlock(
};
});
const lines = [...logicLines, ...matLines];
return {
const block: HopyoBlock = {
key: doc.열,
name: doc.일위대가번 || doc.열,
unit: doc.단위 ?? "",
@@ -85,11 +87,20 @@ export function laborBlock(
lines,
...(calc ? {} : { blank: 0 }),
};
return withSpecs(block, doc, calc);
}
/** 격자 하나(읽기 전용) */
/** 격자 하나 — `edit` 있으면 위에 규격 줄 · 규격 수량 칸 고침(704-4) */
export const laborGrid = (
doc: LaborDoc,
calc: LaborCalc | null,
logics: ReadonlyMap<string, ItemLogic>,
): HTMLElement => createHopyoGrid([laborBlock(doc, calc, logics)]).root;
edit?: SpecEdit | false,
): HTMLElement => {
const block = laborBlock(doc, calc, logics);
if (!edit) return createHopyoGrid([block]).root;
const grid = createHopyoGrid([block], specCells(block, doc, edit)).root;
const box = document.createElement("div");
box.append(specBar(doc, calc, edit), grid);
return box;
};
@@ -0,0 +1,189 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Spec.ts
* 일위대가 규격 표(PLAN 704-4) — 704-3 격자에 규격 열(H=1.5 | 2.0 …)을 폄 · 고칠 때 격자 위 규격 줄.
* 문서 `규격` = {이름, 열, 기본, 수량: {줄 id: {열: 수}}}(짝 `M02_Labor_Spec.py`) · 화면 캐시만(저장은 [저장])
* 규격 줄 = 규격 이름 · 열마다 이름 칸 · [기본] · [×] · [+ 열](새 열 수량 = 지금 기본 열 값 그대로)
* 격자 수량 칸(열마다) 고침 = 그 줄 · 그 열 수량(빈 글 = 공통 수량을 따름)
* 열마다 수량 · 1 단위 합계 = 짝 셈 `settle(…, 열)`(서버 안 부름) · 금액 칸 = 기본 열
* 쓰는 사본이 있는 열 지움 · 이름 바꿈은 [저장] 때 서버가 막음(409 까닭 그대로)
* ========================================================================== */
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import type { DataTableOptions } from "@ui/ui_template_data_table";
import { hopyoGrid, hopyoSpecKey, type HopyoBlock } from "@ui/ui_template_hopyo_grid";
import { createToolbar } from "@ui/ui_template_toolbar";
import { createText } from "@ui/ui_template_text";
import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api";
import { givenOf, lineId, settle, specColumn } from "./M02_MasterTemplete_Labor_Calc";
/** 고칠 때 — 규격이 바뀌면 `true`(수량만 · 기준 그대로 · 짝 셈으로 바로) */
export type SpecEdit = (qtyOnly: boolean) => void;
const TEXT = {
none: "규격 열 없음 — [+ 열] = H 같은 규격마다 줄 수량만 다른 열",
add: "+ 열",
name: "규격 이름",
base: "기본",
remove: "열 지움",
first: "1",
next: "새 열",
};
/** 줄 id 차례 — 격자 줄 차례(로직 줄 → 자재 줄)와 같음 */
const lineIds = (doc: LaborDoc): string[] => [
...doc.담은로직.map((r) => lineId(r) ?? ""),
...doc.자재.map((m) => m.코드),
];
/** 열마다 셈 → 블록 `specs` · 줄 `qtys`(규격 없음 = 그대로) */
export function withSpecs(block: HopyoBlock, doc: LaborDoc, calc: LaborCalc | null): HopyoBlock {
const spec = doc.규격;
if (!spec?.열.length) return block;
const per = calc ? spec.열.map((c) => settle(calc, doc, givenOf(calc), c)) : [];
const ids = lineIds(doc);
const logics = doc.담은로직.length;
const qtyOf = (p: LaborCalc, n: number): number | null =>
(n < logics
? p.줄.find((l) => lineId(l) === ids[n])?.수량
: p.자재.find((m) => m.코드 === ids[n])?.수량) ?? null;
return {
...block,
specs: {
name: spec.이름,
cols: spec.열,
pick: specColumn(doc),
totals: per.map((p) => p.반영합?.계 ?? null),
},
lines: block.lines.map((ln, n) => ({ ...ln, qtys: per.map((p) => qtyOf(p, n)) })),
};
}
/** 격자 수량 칸 고침(열마다) — 공용 표 옵션 덧 */
export function specCells(
block: HopyoBlock,
doc: LaborDoc,
edit: SpecEdit,
): Partial<DataTableOptions> {
const spec = doc.규격;
if (!spec?.열.length) return {};
const g = hopyoGrid([block]);
const ids = lineIds(doc);
const col = (key: string): string | undefined => spec.열.find((_, n) => hopyoSpecKey(n) === key);
return {
editable: (i, key) => g.at.has(i) && col(key) !== undefined,
onEdit: (i, key, text) => {
const id = ids[g.at.get(i)?.[1] ?? -1];
const name = col(key);
if (id === undefined || name === undefined) return;
const v = Number(text.replace(/,/g, "").trim());
const row = { ...(spec.수량[id] ?? {}) };
if (text.trim() === "" || !Number.isFinite(v)) delete row[name];
else row[name] = v;
if (Object.keys(row).length) spec.수량[id] = row;
else delete spec.수량[id];
edit(true);
},
};
}
/** 새 열 이름 — 겹치면 뒤에 번호 */
const fresh = (cols: string[], want: string): string => {
let out = want;
for (let n = 2; cols.includes(out); n++) out = `${want} ${n}`;
return out;
};
/** [+ 열] — 규격이 없으면 지금 수량 열(1) + 새 열 · 새 열 수량 = 기본 열 손 값 그대로 */
function addColumn(doc: LaborDoc, calc: LaborCalc | null): void {
const spec = (doc.규격 ??= { 이름: "H", 열: [TEXT.first], 기본: TEXT.first, 수량: {} });
const col = fresh(spec.열, TEXT.next);
const base = specColumn(doc) ?? spec.열[0];
spec.열.push(col);
const ids = lineIds(doc);
const logics = doc.담은로직.length;
ids.forEach((id, n) => {
const own = spec.수량[id]?.[base];
const line =
n < logics ? calc?.줄.find((l) => lineId(l) === id) : calc?.자재.find((m) => m.코드 === id);
const v = own ?? (line?.수량출처 === "손" ? line.수량 : null);
if (v != null) spec.수량[id] = { ...(spec.수량[id] ?? {}), [col]: v };
});
}
function renameColumn(doc: LaborDoc, from: string, to: string): boolean {
const spec = doc.규격;
const name = to.trim();
if (!spec || !name || name.length > 20 || name === from || spec.열.includes(name)) return false;
spec.열 = spec.열.map((c) => (c === from ? name : c));
if (spec.기본 === from) spec.기본 = name;
for (const row of Object.values(spec.수량))
if (from in row) {
row[name] = row[from];
delete row[from];
}
return true;
}
function removeColumn(doc: LaborDoc, col: string): void {
const spec = doc.규격;
if (!spec) return;
spec.열 = spec.열.filter((c) => c !== col);
for (const [id, row] of Object.entries(spec.수량)) {
delete row[col];
if (!Object.keys(row).length) delete spec.수량[id];
}
if (!spec.열.length)
doc.규격 = null; // 마지막 열 = 규격 없음(옛 문서 꼴)
else if (spec.기본 === col) spec.기본 = spec.열[0];
}
/** 격자 위 규격 줄(고칠 때만) */
export function specBar(doc: LaborDoc, calc: LaborCalc | null, edit: SpecEdit): HTMLElement {
const add = createButton({
label: TEXT.add,
variant: "ghost",
onClick: () => {
addColumn(doc, calc);
edit(true);
},
});
const spec = doc.규격;
if (!spec?.열.length)
return createToolbar({ items: [createText(TEXT.none, "muted"), add], label: TEXT.name });
const name = createInputField({ value: spec.이름, compact: true, placeholder: TEXT.name });
name.input.title = TEXT.name;
name.input.addEventListener("change", () => {
const v = name.input.value.trim();
if (v && v.length <= 20) spec.이름 = v;
edit(true);
});
const cols = spec.열.flatMap((col) => {
const box = createInputField({ value: col, compact: true });
box.input.setAttribute("data-spec-col", col);
box.input.addEventListener("change", () => {
renameColumn(doc, col, box.input.value);
edit(true);
});
const base = createButton({
label: TEXT.base,
variant: col === spec.기본 ? "filled" : "ghost",
size: "sm",
onClick: () => {
spec.기본 = col;
edit(true);
},
});
const drop = createButton({
label: "×",
variant: "ghost",
size: "sm",
onClick: () => {
removeColumn(doc, col);
edit(true);
},
});
drop.title = TEXT.remove;
return [el("span", { children: [box.root] }), base, drop, "divider" as const];
});
return createToolbar({ items: [name.root, "divider", ...cols, add], label: TEXT.name });
}
+2 -1
View File
@@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
def test_M02_문서_calc_를_격자_줄로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
logics = [
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
@@ -134,7 +135,7 @@ def test_M02_문서_calc_를_격자_줄로():
def test_세_곳_같은_부품():
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
+56
View File
@@ -168,3 +168,59 @@ def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
spec.guard(path, {"규격": SPEC}, "ms")
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
def test_격자_규격_열_수량_칸_열마다():
from resources.tester import test_704_3_hopyo_grid as g
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}]
out = g._node(files, (
f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());"
"\nconst grid = hopyoGrid([b]);"
"\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group]),"
" rows: grid.rows, specs: b.specs }));"
), g.STUBS) # fmt: skip
cols = [c for c in out["cols"] if c[0].startswith("qty")]
assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]]
line = out["rows"][1] # 첫 구성 줄
assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09
mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌")
assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량
t15, t20 = out["specs"]["totals"]
assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합
assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖
def test_고르기_창_spec_돌려줌():
import subprocess
data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts"
body = (
data.read_text("utf-8")
+ """
const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"],
labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } };
const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } };
const plain = { key: "SS000001", 열: "rv" };
const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" };
console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"),
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)]));
"""
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
two, single, plain, *starts = json.loads(got.stdout)
assert two == {"layer": "system", "owner": None, "key": "SS000022",
"name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip
assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로
assert "spec" not in plain # 규격 없는 옛 항목
assert starts == ["1.5", "2.0", "2.0", None]
+38 -1
View File
@@ -8,6 +8,8 @@
* 구성 줄 = 「ㄴ 품명」 · 규격 · 품/수량 · 단위 · 합계 · 노무비 · 재료비 · 경비(단가 / 금액 쌍) · 비고 · 구분(줄 꼬리표)
* 합계 줄 = 비목 금액 합 · 빈 줄(금액 모름 · 수량 없음) 있으면 비고 「빈 줄 n」 붉게 — 합계는 아는 줄만
* 줄 꼬리표 = 「품셈」(우리 로직이 품을 셈) · 「고정」 갈래는 705 몫(자리만 · 같은 칸)
* 규격 열(704-4 · 첫 호표 `specs`) = 수량 칸이 열 수만큼(`qty_n` · 머리 「H 열」) · 합계 줄 그 칸 = 열마다 1 단위 합계 ·
* 금액 칸 = 기본 열 값
* 값은 부르는 쪽이 준 것 그대로(서버 계산 값) — 여기서 셈하지 않음
*
* const g = createHopyoGrid([block]); host.append(g.root); g.set([block2]);
@@ -44,6 +46,16 @@ export interface HopyoLine {
note?: string | null;
/** 줄 꼬리표 — 「품셈」 · 「고정」(705) · 빈 값 = 없음 */
tag?: string | null;
/** 규격 열마다 품 · 수량(704-4 · 블록 `specs.cols` 차례) — 없으면 `qty` */
qtys?: (number | null)[];
}
/** 규격 열(704-4) — 이름(H) · 열 이름 · 기본 열 · 열마다 1 단위 합계 */
export interface HopyoSpecs {
name: string;
cols: string[];
pick?: string | null;
totals: (number | null)[];
}
export interface HopyoBlock {
@@ -63,6 +75,8 @@ export interface HopyoBlock {
/** 빈 줄 수 · 이름 — 없으면 금액 모르는 줄에서 셈 */
blank?: number;
blank_names?: string[];
/** 규격 열(704-4) — 첫 호표 것만 열로 폄 */
specs?: HopyoSpecs | null;
}
/** 줄 꼬리표 낱말 */
@@ -106,6 +120,23 @@ export const HOPYO_COLUMNS: DataColumn[] = [
hopyoText("tag", "구분", 56),
];
/** 규격 열 수량 칸 열쇠(704-4) */
export const hopyoSpecKey = (n: number): string => `qty_${n}`;
/** 열 — 첫 호표에 규격 열이 있으면 수량 칸을 열마다 폄(기본 열 = 「(기본)」) */
export function hopyoColumns(blocks: HopyoBlock[]): DataColumn[] {
const specs = blocks[0]?.specs;
if (!specs?.cols.length) return HOPYO_COLUMNS;
const qty = HOPYO_COLUMNS.find((c) => c.key === "qty") as DataColumn;
const cols = specs.cols.map((col, n): DataColumn => ({
...qty,
key: hopyoSpecKey(n),
label: col === specs.pick ? `${col} (기본)` : col,
group: [`${specs.name} 열`],
}));
return HOPYO_COLUMNS.flatMap((c) => (c.key === "qty" ? cols : [c]));
}
export interface HopyoGrid {
columns: DataColumn[];
rows: DataRow[];
@@ -126,6 +157,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
const rows: DataRow[] = [];
const titles = new Set<number>();
const at = new Map<number, [number, number]>();
const specCount = blocks[0]?.specs?.cols.length ?? 0;
blocks.forEach((b, bi) => {
titles.add(rows.length);
rows.push({
@@ -153,6 +185,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
note: ln.note ?? "",
tag: ln.tag ?? "",
};
for (let k = 0; k < specCount; k++) row[hopyoSpecKey(k)] = ln.qtys?.[k] ?? ln.qty;
for (const [key] of HOPYO_COSTS) {
row[`${key}_price`] = ln.prices[key] ?? null;
row[key] = ln.amounts[key] ?? null;
@@ -161,7 +194,11 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
});
const blank = hopyoBlank(b);
titles.add(rows.length);
const sums = Object.fromEntries(
Array.from({ length: specCount }, (_, k) => [hopyoSpecKey(k), b.specs?.totals[k] ?? null]),
);
rows.push({
...sums,
key: `${b.key}|합계`,
name: "합 계",
sum: b.total.sum ?? null,
@@ -173,7 +210,7 @@ export function hopyoGrid(blocks: HopyoBlock[]): HopyoGrid {
blank_names: blank.names,
});
});
return { columns: HOPYO_COLUMNS, rows, titles, at };
return { columns: hopyoColumns(blocks), rows, titles, at };
}
/** 칸 꼴 — 꼬리표 · 빈 줄 알림 */
+42 -2
View File
@@ -11,6 +11,8 @@
* 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐).
* 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭.
* 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목.
* 704-4 — 고른 항목에 규격 열이 둘 이상이면 표 아래 열 단추(열마다 1 단위 합계) · [적용] = `spec` 붙은 참조 ·
* 처음 = 지금 지정 열(같은 항목) · 아니면 기본 · 열 하나 = 단추 없음(그 열로).
* ========================================================================== */
import "./ui_template_item_picker.css";
@@ -25,6 +27,8 @@ import {
ownerOf,
pathIds,
rowId,
specOf,
startSpec,
tableKey,
toSummary,
type PickNode,
@@ -47,6 +51,8 @@ import { projectItems, type CreatedItem } from "./ui_template_item_picker_projec
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
import { createComboTable } from "./ui_template_item_picker_table";
import { createToolbar } from "./ui_template_toolbar";
import { createText } from "./ui_template_text";
export type { SummaryItem } from "./ui_template_item_picker_data";
@@ -166,6 +172,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
children: [comboTable.root],
});
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
/** 규격 열(704-4) — 고른 열 · 그 열을 정한 항목 */
let specPick: string | null = null;
let specFor = "";
const specHost = el("div", { className: "ui-item-picker__spec", attrs: { hidden: "" } });
let editView: EditView | null = null;
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
@@ -194,9 +204,37 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
);
};
const paintSpec = (): void => {
if (chosen && rowId(chosen) !== specFor) {
specFor = rowId(chosen);
specPick = startSpec(chosen, opts.current);
}
const spec = chosen ? specOf(chosen) : null;
specHost.hidden = !spec || spec.열.length < 2;
if (!spec || specHost.hidden) return void specHost.replaceChildren();
const sum = (n: number | null): string =>
n === null ? "" : ` · ${n.toLocaleString("ko-KR", { maximumFractionDigits: 0 })}`;
const buttons = spec.열.map((col) => {
const button = createButton({
label: `${spec.이름}=${col.이름}${sum(col.합계)}`,
variant: col.이름 === specPick ? "filled" : "ghost",
size: "sm",
onClick: () => {
specPick = col.이름;
paintSpec();
},
});
button.setAttribute("aria-pressed", String(col.이름 === specPick));
return button;
});
const head = createText(`${spec.이름} 규격 열`, "muted");
specHost.replaceChildren(createToolbar({ items: [head, ...buttons], label: spec.이름 }));
};
const paintDetail = (): void => {
detailView.show(chosen);
paintTable();
paintSpec();
const layer = chosen ? layerOf(chosen) : null;
applyButton.disabled = !chosen;
clearButton.disabled = !opts.current;
@@ -337,7 +375,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
});
applyButton.addEventListener(
"click",
() => chosen && finish({ action: "apply", item: toSummary(chosen) }),
() => chosen && finish({ action: "apply", item: toSummary(chosen, specPick) }),
);
clearButton.addEventListener("click", () => finish({ action: "clear" }));
manageButton.addEventListener("click", () => {
@@ -407,6 +445,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.replaceChildren(view.root);
cols.hidden = true;
tableWrap.hidden = true;
specHost.hidden = true;
actions.hidden = true;
headButtons.hidden = true;
editHost.hidden = false;
@@ -419,6 +458,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
editHost.hidden = true;
cols.hidden = false;
tableWrap.hidden = false;
paintSpec();
actions.hidden = false;
headButtons.hidden = false;
if (saved) detailView.markEdited();
@@ -440,7 +480,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
return true;
},
mount: (body) => {
body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : []));
body.append(tableWrap, specHost, cols, editHost, ...(opts.projectId ? [actions] : []));
paintDetail();
void load(null);
},
+31 -2
View File
@@ -2,6 +2,7 @@
* ui_template_item_picker_data.ts
* 항목 고르기의 자료 쪽(화면 없음) — 항목 목록 → 나무 · 찾기 · 폴더 줄 · 돌려줄 `summary_item`.
* 모양은 PLAN.md 21-1 약속 그대로: 항목 목록 API 한 줄 = `PickerRow`, 참조 = `SummaryItem`.
* 규격 열(704-4) = 줄 `labor.규격` · 돌려줄 값에 `spec`(열 둘 이상이면 이름 뒤 「(H=2.0)」).
* ========================================================================== */
export type PickerLayer = "system" | "company" | "personal";
@@ -12,6 +13,15 @@ export interface SummaryItem {
owner: string | null;
key: string;
name: string;
/** 규격 열 이름(704-4) — 없으면 기본 열 */
spec?: string;
}
/** 규격 열(704-4 · 서버 목록 `labor.규격`) — 열마다 1 단위 합계 */
export interface PickerSpec {
이름: string;
열: { 이름: string; 합계: number | null }[];
기본: string;
}
/** 항목 줄이 가리키는 키 칸 — 서버가 `names` 로 이름을 함께 주면 이름을 보임 */
@@ -36,6 +46,8 @@ export interface PickerRow {
names?: Partial<Record<NameField, string | null>>;
layer?: PickerLayer;
owner?: string | null;
/** 일위대가 문서 요약(39-5) — 규격 열만 씀 */
labor?: { 규격?: PickerSpec } | null;
}
/** 그룹 차례 = 마스터 · 회사 · 개인 */
@@ -88,8 +100,25 @@ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row)
/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */
export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`;
export function toSummary(row: PickerRow): SummaryItem {
return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
/** 그 항목 규격 열 — 없으면 null */
export const specOf = (row: PickerRow): PickerSpec | null =>
row.labor?.규격?.열?.length ? row.labor.규격 : null;
/** 처음 고른 열 — 지금 지정이 같은 항목이고 그 열이 있으면 그것 · 아니면 기본 */
export function startSpec(row: PickerRow, current: SummaryItem | null): string | null {
const spec = specOf(row);
if (!spec) return null;
const want = isCurrent(current, row) ? current?.spec : undefined;
return spec.열.some((c) => c.이름 === want) ? (want as string) : spec.기본;
}
/** 돌려줄 참조 — 규격 열이 있으면 `spec`(열 둘 이상 = 이름 뒤 「(H=열)」) */
export function toSummary(row: PickerRow, col: string | null = null): SummaryItem {
const out = { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) };
const spec = specOf(row);
if (!spec || col === null) return out;
const name = spec.열.length > 1 ? `${out.name} (${spec.이름}=${col})` : out.name;
return { ...out, name, spec: col };
}
export function isCurrent(current: SummaryItem | null, row: PickerRow): boolean {