Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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>` : "";
|
||||
|
||||
Reference in New Issue
Block a user