Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5

This commit is contained in:
2026-10-10 12:06:00 +09:00
7 changed files with 197 additions and 29 deletions
@@ -106,3 +106,9 @@
.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child {
--pick-r: inset -2px 0 0 var(--color-danger);
}
/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */
.b09-estimation .ui-data-table__table tbody td.is-picked {
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
font-weight: var(--font-weight-bold);
}
+13 -1
View File
@@ -49,7 +49,7 @@ import {
buildMaterial,
} from "./B09_Estimation_UI_Tabs_Base";
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import {
buildSumExpense,
buildSumLabor,
@@ -206,6 +206,7 @@ export function renderB09Estimation(root: HTMLElement): void {
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
cellClass: spec.cellClass,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
@@ -215,6 +216,17 @@ export function renderB09Estimation(root: HTMLElement): void {
// 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임
const menu = (spec as BillTable).menu;
if (menu) attachRowMenu(made.root, menu);
// 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴
if (spec.choices)
made.root.addEventListener("click", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
const column = cell ? spec.columns[cell.cellIndex] : undefined;
const items =
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
if (!cell || !items?.length) return;
const box = cell.getBoundingClientRect();
openMenuAt(box.left, box.bottom, items);
});
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
if (spec.tip)
made.root.addEventListener("mouseover", (event) => {
+5
View File
@@ -7,6 +7,7 @@
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
@@ -107,6 +108,10 @@ export interface TabTable {
rowClass?: (index: number) => string | null | undefined;
/** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */
tip?: (index: number, key: string) => string | null | undefined;
/** 칸 꼴 class(183-3 공용 표 `cellClass` · 예 고른 출처 칸 강조) */
cellClass?: (index: number, key: string) => string | null | undefined;
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
+34 -21
View File
@@ -5,13 +5,15 @@
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
*
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
* · 견적 단가 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
* 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 →
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
@@ -109,10 +111,18 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */
function nextPick(r: Row): string | null {
/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 ·
* `pick(s)` = 그 출처 · null = 자동 */
function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] {
const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0);
return have[have.indexOf(r.pick) + 1] ?? null;
const label = (s: string): string =>
`${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` +
(s === r.lowest_from ? " · 최저" : "");
return [
...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]),
"-",
[`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)],
];
}
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
@@ -162,21 +172,30 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "applied" || key === "p:견적",
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "p:견적",
onEdit: (i, key, value) => {
const row = out[i];
if (!row) return;
const cell = mine(String(row.key));
const v = parseNumber(value);
if (v === undefined) return;
const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null;
if (!field) return;
if (v === null) delete cell[field];
else cell[field] = v;
if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂
if (!row || key !== "p:견적" || v === undefined) return;
const cell = mine(String(row.key));
if (v === null) delete cell.quote;
else cell.quote = v;
put(String(row.key), cell);
},
// 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴
cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null),
choices: (i, key) => {
const r = rows[i];
if (key !== "applied" || !r) return null;
return appliedChoices(r, (s) => {
const cell = mine(r.key);
delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂
if (s) cell.pick = s;
else delete cell.pick;
put(r.key, cell);
});
},
onCell: (i, key) => {
const row = out[i];
const r = rows[i];
@@ -192,12 +211,6 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
put(r.key, now);
});
}
if (key === "applied_from") {
const pick = nextPick(r);
if (pick) cell.pick = pick;
else delete cell.pick;
return put(String(row.key), cell);
}
if (key !== "supply") return;
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
if (next) cell.supply = next;
@@ -348,17 +348,27 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
}
/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */
function sharedMenu(): MapContextMenu {
shared ??= createMapContextMenu("b09-estimation");
if (!shared.element.isConnected) {
shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(shared.element);
}
return shared;
}
/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */
export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void {
if (items.length) sharedMenu().open(x, y, items);
}
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
export function attachRowMenu(
root: HTMLElement,
menu: (index: number) => MapContextMenuItem[],
): void {
shared ??= createMapContextMenu("b09-estimation");
const ctxMenu = shared;
if (!ctxMenu.element.isConnected) {
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(ctxMenu.element);
}
const ctxMenu = sharedMenu();
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
root.addEventListener(
"click",
@@ -0,0 +1,113 @@
"""183-3 자재단가대비표 적용 단가 — 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저.
- 공용 표 `cellClass` 옵션(칸 class · 안 주면 같은 글) — Node 로 `renderDataTableHtml`
- 고르기 항목 `appliedChoices`(값 있는 출처 · 견적 · 최저(자동) · ✓ 표시) — Node 로 함수만 떼어 돌림
- 배선: 수기 적용 칸 · 출처 칸 돌림 없앰 · Shell 이 cellClass · choices 를 넘김 · B09 메뉴 한 벌
셈 · 저장(pick › applied › 최저)은 그대로(test_144 · test_166_4).
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "config" / "node_modules" / "typescript"
def _src(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _node(script: str):
out = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
PRELUDE = f"""
const fs = require("fs");
const ts = require({json.dumps(str(TS))});
const js = (code) => ts.transpileModule(code, {{
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
}}).outputText;
"""
def test_공용_표_cellClass_칸_class_안_주면_같은_글():
table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts"))
got = _node(
PRELUDE
+ f"""
const box = {{ exports: {{}} }};
new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))(
box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p)));
const m = box.exports;
const o = {{ columns: [{{ key: "a", label: "A" }}, {{ key: "b", label: "B" }}],
rows: [{{ a: 1, b: 2 }}, {{ a: 3, b: 4 }}] }};
const marked = m.renderDataTableHtml({{ ...o,
cellClass: (i, k) => (i === 0 && k === "b" ? "is-picked" : null) }});
console.log(JSON.stringify({{
plain: m.renderDataTableHtml(o),
none: m.renderDataTableHtml({{ ...o, cellClass: () => null }}),
count: marked.split("is-picked").length - 1,
row0: marked.split("<tbody>")[1].split("</tr>")[0],
}}));
"""
)
assert got["plain"] == got["none"]
assert got["count"] == 1 and '<td class="is-picked">2</td>' in got["row0"]
def test_고르기_항목_값_있는_출처_견적_최저_자동():
base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts")
start = base.index("function appliedChoices")
body = base[start : base.index("\n}\n", start) + 3]
sources = _src("B09_Estimation/B09_Estimation_Calc_Base.ts")
at = sources.index("export const SOURCES")
line = sources[at : sources.index(";", at) + 1] + "\n"
got = _node(
PRELUDE
+ f"""
const appliedChoices = new Function(js({json.dumps(line.replace("export ", "") + body)}) + "; return appliedChoices;")();
const r = {{ prices: {{ 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: 1500 }},
lowest_from: "유통물가", applied_from: "유통물가", pick: null }};
const seen = [];
const items = appliedChoices(r, (s) => seen.push(s));
items.forEach((x) => Array.isArray(x) && x[1]());
const pick = {{ ...r, applied_from: "견적", pick: "견적" }};
console.log(JSON.stringify({{
labels: items.map((x) => (Array.isArray(x) ? x[0] : x)),
seen,
picked: appliedChoices(pick, () => 0).map((x) => (Array.isArray(x) ? x[0] : x)),
}}));
"""
)
assert got["labels"] == [
"물가정보 1,200",
"✓ 유통물가 1,000 · 최저",
"견적 1,500",
"-",
"✓ 최저(자동)",
]
assert got["seen"] == ["물가정보", "유통물가", "견적", None]
assert got["picked"][2] == "✓ 견적 1,500" and got["picked"][-1] == "최저(자동)"
def test_배선_수기_적용_칸_출처_돌림_없앰():
base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts")
compare = base[base.index("export const buildCompare") : base.index("function usesOf")]
assert 'editable: (_i, key) => key === "p:견적",' in compare
assert "nextPick" not in base and 'key === "applied_from"' not in compare
assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare
assert 'if (key !== "applied" || !r) return null;' in compare
assert "delete cell.applied" in compare
shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts")
assert (
"cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell
)
edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts")
assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit
css = _src("B09_Estimation/B09_Estimation_UI_Page.css")
assert "td.is-picked" in css and "var(--color-accent)" in css
+10 -1
View File
@@ -60,6 +60,8 @@ export interface DataTableOptions {
onEdit?: (index: number, key: string, text: string) => void;
/** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */
rowClass?: (index: number) => string | null | undefined;
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
cellClass?: (index: number, key: string) => string | null | undefined;
}
export interface DataTable {
@@ -139,6 +141,7 @@ function rowHtml(
editable?: (index: number, key: string) => boolean,
focus = false,
extra: string | null | undefined = null,
cellClass?: (key: string) => string | null | undefined,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
@@ -152,7 +155,12 @@ function rowHtml(
}
const [txt, empty] = formatCell(r[key], fmts[j], blank);
const frz = left[j] !== null;
const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"]
const cls = [
frz && "is-frozen",
frz && left[j + 1] == null && "is-edge",
empty && "is-empty",
cellClass?.(key),
]
.filter(Boolean)
.join(" ");
const attrs =
@@ -218,6 +226,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
o.editable,
o.focus === i,
o.rowClass?.(i),
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";