Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_6
This commit is contained in:
@@ -252,29 +252,39 @@ function unescapeMsoNumFmt(v: string): string {
|
||||
return v.replace(/^"|"$/g, "").replace(/\\(.)/g, "$1");
|
||||
}
|
||||
|
||||
const BORDER_STYLE_MAP: Record<string, BorderLine> = {
|
||||
solid: "thin",
|
||||
dashed: "dashed",
|
||||
dotted: "dotted",
|
||||
double: "double",
|
||||
};
|
||||
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
|
||||
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
|
||||
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
|
||||
if (cssStyle === "dashed") return "dashed";
|
||||
if (cssStyle === "dotted") return "dotted";
|
||||
if (cssStyle === "double") return "double";
|
||||
if (pt <= 0.5) return "hair";
|
||||
if (pt <= 1.25) return "thin";
|
||||
if (pt <= 2) return "medium";
|
||||
return "thick";
|
||||
}
|
||||
|
||||
function parseBorderShorthand(v: string): BorderSide | undefined {
|
||||
const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v);
|
||||
if (!m) return undefined;
|
||||
const 선 = BORDER_STYLE_MAP[m[2].toLowerCase()] ?? "thin";
|
||||
const 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
|
||||
const 색 = cssColorToHex(m[3]);
|
||||
return 색 ? { 선, 색 } : { 선 };
|
||||
}
|
||||
|
||||
/** `<style>...</style>` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */
|
||||
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
|
||||
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
|
||||
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
|
||||
const css = Array.from(doc.querySelectorAll("style"))
|
||||
.map((s) => s.textContent ?? "")
|
||||
.join("\n");
|
||||
const rules: Record<string, Record<string, string>> = {};
|
||||
for (const m of css.matchAll(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) {
|
||||
rules[m[1]] = parseStyleAttr(m[2]);
|
||||
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
||||
const decls = parseStyleAttr(m[2]);
|
||||
for (const selectorPart of m[1].split(",")) {
|
||||
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
|
||||
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
|
||||
}
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"판": 1,
|
||||
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
|
||||
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)",
|
||||
"층": ["종류", "공법", "규격"],
|
||||
"층": ["종류", "분류1", "분류2"],
|
||||
"변수": {
|
||||
"파형강관_1본_m": 8,
|
||||
"흄관_1본_m": "",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- fixture_excel_style_paste.html — 엑셀이 표 서식(머리 채움 · 가는 선 · 합계 줄 위 굵은 선 · 숫자
|
||||
쉼표)을 실제로 어떻게 싣는지 재현(브레인 9-28 사용자 지시 근거 = PLAN.md 12장). 서식 대부분이
|
||||
인라인 style 이 아니라 <style> 안 클래스(.xl65 같은 이름 · 묶음 선택자 포함)에 있고 칸에는
|
||||
class 만 두는 실제 엑셀 내보내기 짜임을 그대로 씀 — `parseHtmlTable`/`styleFromTd` 가 클래스
|
||||
규칙을 인라인과 합쳐 읽는지 이 견본으로 시험. -->
|
||||
<html
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
xmlns:x="urn:schemas-microsoft-com:office:excel"
|
||||
xmlns="http://www.w3.org/TR/REC-html40"
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
<!--table
|
||||
{mso-displayed-decimal-separator:"\.";
|
||||
mso-displayed-thousand-separator:"\,";}
|
||||
.xl_head, .xl_head2
|
||||
{background:#D9D9D9;
|
||||
font-weight:700;
|
||||
text-align:center;
|
||||
border:.5pt solid windowtext;}
|
||||
.xl_num
|
||||
{mso-number-format:"\#\,\#\#0";
|
||||
text-align:right;
|
||||
border:.5pt solid windowtext;}
|
||||
.xl_label
|
||||
{border:.5pt solid windowtext;}
|
||||
.xl_total_label
|
||||
{font-weight:700;
|
||||
border-left:.5pt solid windowtext;
|
||||
border-right:.5pt solid windowtext;
|
||||
border-bottom:.5pt solid windowtext;
|
||||
border-top:1.5pt solid windowtext;}
|
||||
.xl_total_num
|
||||
{mso-number-format:"\#\,\#\#0";
|
||||
font-weight:700;
|
||||
text-align:right;
|
||||
border-left:.5pt solid windowtext;
|
||||
border-right:.5pt solid windowtext;
|
||||
border-bottom:.5pt solid windowtext;
|
||||
border-top:1.5pt solid windowtext;}
|
||||
-->
|
||||
</style>
|
||||
</head>
|
||||
<body link="#0563C1" vlink="#954F72">
|
||||
<!--StartFragment-->
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
width="288"
|
||||
style="border-collapse: collapse; width: 216pt"
|
||||
>
|
||||
<tr height="20" style="height: 15pt">
|
||||
<td class="xl_head" width="96" style="width: 72pt" x:str>품목</td>
|
||||
<td class="xl_head2" width="96" style="width: 72pt" x:str>수량</td>
|
||||
<td class="xl_head2" width="96" style="width: 72pt" x:str>금액</td>
|
||||
</tr>
|
||||
<tr height="20" style="height: 15pt">
|
||||
<td class="xl_label" x:str>터파기</td>
|
||||
<td class="xl_num" x:num>120</td>
|
||||
<td class="xl_num" x:num>1200000</td>
|
||||
</tr>
|
||||
<tr height="20" style="height: 15pt">
|
||||
<td class="xl_label" x:str>메우기</td>
|
||||
<td class="xl_num" x:num>80</td>
|
||||
<td class="xl_num" x:num>800000</td>
|
||||
</tr>
|
||||
<tr height="20" style="height: 15pt">
|
||||
<td class="xl_total_label" x:str>합계</td>
|
||||
<td class="xl_total_num" x:num>200</td>
|
||||
<td class="xl_total_num" x:num>2000000</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--EndFragment-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
|
||||
|
||||
도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음.
|
||||
도면 줄 `s:drawing` · 산출근거 줄 `s:basis` = master 만 · 일위대가 줄 바로 아래 차례(키보드) · project 에는 없음.
|
||||
`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null).
|
||||
"""
|
||||
|
||||
@@ -51,7 +51,7 @@ def _run() -> dict:
|
||||
def test_drawing_row_under_price_master_only():
|
||||
got = _run()
|
||||
assert got["master"] == [
|
||||
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "d:r1", "t:t1",
|
||||
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "s:basis", "d:r1", "t:t1",
|
||||
] # fmt: skip
|
||||
assert got["project"] == ["s:unit", "d:r1", "t:t1"]
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""표 둘째 층 묶음 더하기 · 층 이름 고치기 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
|
||||
|
||||
`addGroup` = 고른 열과 같은 맨 위 묶음(종류) 끝에 `[종류, 새 분류, …, 새 열]` · 다른 종류 열은 안 건드림.
|
||||
`setLayerName` = 층 이름(줄 머리 글) · 모자란 층은 빈 글로 채움.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
SCRIPT = """
|
||||
const { addGroup, setLayerName } = await import(process.argv[1]);
|
||||
const base = JSON.parse(process.argv[2]);
|
||||
const out = {};
|
||||
let doc = structuredClone(base);
|
||||
out.id = addGroup(doc, "a1", 3, "새 분류", "새 열");
|
||||
out.after_a1 = doc.열.map((c) => [c.id, c.머리]);
|
||||
doc = structuredClone(base);
|
||||
addGroup(doc, "b1", 4, "G", "L");
|
||||
out.deep = doc.열.at(-1).머리;
|
||||
doc = structuredClone(base);
|
||||
addGroup(doc, null, 3, "G", "L");
|
||||
out.none = doc.열.at(-1).머리;
|
||||
doc = structuredClone(base);
|
||||
setLayerName(doc, 1, " 분류1 ");
|
||||
out.layers = doc.층;
|
||||
doc = structuredClone(base);
|
||||
delete doc.층;
|
||||
setLayerName(doc, 2, "분류2");
|
||||
out.padded = doc.층;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
DOC = {
|
||||
"양식": "시험",
|
||||
"종류": "표",
|
||||
"판": 1,
|
||||
"층": ["종류", "공법", "규격"],
|
||||
"열": [
|
||||
{"id": "a1", "머리": ["가", "x", "1"], "단위": "m"},
|
||||
{"id": "a2", "머리": ["가", "x", "2"]},
|
||||
{"id": "a3", "머리": ["가", "y", None]},
|
||||
{"id": "b1", "머리": ["나", None, None]},
|
||||
],
|
||||
"줄": [],
|
||||
}
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
|
||||
SCRIPT, OPS.as_uri(), json.dumps(DOC, ensure_ascii=False)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_group_goes_to_end_of_same_top():
|
||||
got = _run()
|
||||
assert got["id"] == "c1"
|
||||
assert got["after_a1"] == [
|
||||
["a1", ["가", "x", "1"]],
|
||||
["a2", ["가", "x", "2"]],
|
||||
["a3", ["가", "y", None]],
|
||||
["c1", ["가", "새 분류", "새 열"]],
|
||||
["b1", ["나", None, None]],
|
||||
]
|
||||
|
||||
|
||||
def test_group_depth_and_no_selection():
|
||||
got = _run()
|
||||
assert got["deep"] == ["나", "G", None, "L"]
|
||||
assert got["none"] == ["G", "G", "L"]
|
||||
|
||||
|
||||
def test_layer_name():
|
||||
got = _run()
|
||||
assert got["layers"] == ["종류", "분류1", "규격"]
|
||||
assert got["padded"] == ["", "", "분류2"]
|
||||
@@ -123,6 +123,13 @@
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
/* 조회 후 선택 칸 — 두 번 누르거나 Enter 로 고름 */
|
||||
.ui-sheet__table td.is-pick {
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-selected {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.ts
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`.
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, basisLabel, onSelect, onPick})`
|
||||
* → `{getDoc, setDoc, recalc, destroy}`.
|
||||
*
|
||||
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
|
||||
* 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`.
|
||||
* master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 ·
|
||||
* 둘째 층 묶음 더하기 · 손 열.
|
||||
* 도면 · 산출근거 줄 = `drawingLabel` · `basisLabel` 글 · 칸을 고르면 `onSelect` ·
|
||||
* `onPick` 을 주면 일위대가 · 도면 · 산출근거 칸은 치지 않고 조회 후 선택.
|
||||
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
||||
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
@@ -14,27 +17,27 @@ import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import {
|
||||
addColumn,
|
||||
addGroup,
|
||||
addRow,
|
||||
canDeleteRow,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
KEY_BASIS,
|
||||
KEY_DRAWING,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
PICK_ROWS,
|
||||
selectionOf,
|
||||
type SheetMode,
|
||||
setCell,
|
||||
setColumnWidth,
|
||||
setHeadLabel,
|
||||
setLayerName,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { headDepth } from "./ui_template_sheet_header";
|
||||
import { recalcSheet } from "./ui_template_sheet_recalc";
|
||||
import {
|
||||
drawingText,
|
||||
keyEditable,
|
||||
type RenderState,
|
||||
renderSheet,
|
||||
} from "./ui_template_sheet_render";
|
||||
import { keyEditable, refText, type RenderState, renderSheet } from "./ui_template_sheet_render";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
|
||||
import "./ui_template_sheet.css";
|
||||
@@ -47,8 +50,14 @@ export interface SheetOptions {
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
/** 산출근거 줄 칸 글(표번) — `drawingLabel` 과 같음. */
|
||||
basisLabel?: (colId: string) => string;
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 산출근거 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
* 글을 돌려주면 일위대가 칸에 넣음 · 도면 · 산출근거는 부른 쪽이 가져온 뒤 `recalc()`.
|
||||
* 없으면 일위대가는 글로 고침 · 도면 · 산출근거는 읽기만. */
|
||||
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
|
||||
}
|
||||
|
||||
export interface SheetHandle {
|
||||
@@ -67,6 +76,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
drawingLabel: opts.drawingLabel,
|
||||
basisLabel: opts.basisLabel,
|
||||
pick: !!opts.onPick,
|
||||
};
|
||||
const scroll = el("div", { className: "ui-sheet__scroll" });
|
||||
const toolbar = el("div", { className: "ui-sheet__toolbar" });
|
||||
@@ -103,6 +114,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
|
||||
changed();
|
||||
});
|
||||
const groupAdd = button(st("Group_Add"), () => {
|
||||
const depth = headDepth(state.doc.열, state.doc.층);
|
||||
const id = addGroup(state.doc, state.sel?.c ?? null, depth, st("Group_New"), st("Col_New"));
|
||||
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
|
||||
changed();
|
||||
});
|
||||
const colDelete = button(st("Col_Delete"), () => {
|
||||
const id = state.sel?.c;
|
||||
if (!id || state.doc.열.length <= 1) return;
|
||||
@@ -120,13 +137,20 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
changed();
|
||||
});
|
||||
toolbar.append(rowAdd, rowDelete);
|
||||
if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand);
|
||||
if (opts.mode === "master") toolbar.append(colAdd, groupAdd, colDelete, colHand);
|
||||
|
||||
const syncToolbar = (): void => {
|
||||
const rowId = rowIdOf(state.sel?.r);
|
||||
const row = state.doc.줄.find((r) => r.id === rowId);
|
||||
rowDelete.disabled = !row || !canDeleteRow(state.mode, row);
|
||||
const col = state.sel ? colOf(state.sel.c) : undefined;
|
||||
// 둘째 층 묶음 — 층이 셋 이상일 때만 · 이름은 둘째 층 이름(「분류1 더하기」)
|
||||
const group = state.doc.층?.[1]?.trim();
|
||||
groupAdd.hidden = headDepth(state.doc.열, state.doc.층) < 3;
|
||||
groupAdd.disabled = !col;
|
||||
groupAdd.querySelector(".ui-btn__label")!.textContent = group
|
||||
? `${group}${st("Add_Suffix")}`
|
||||
: st("Group_Add");
|
||||
colDelete.disabled = !col || state.doc.열.length <= 1;
|
||||
colHand.disabled = !col;
|
||||
colHand.classList.toggle("is-on", !!col?.손);
|
||||
@@ -180,8 +204,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
if (key === KEY_UNIT) return col.단위 ?? "";
|
||||
if (key === "s:formula") return col.식 ?? "";
|
||||
if (key === "s:desc") return col.설명 ?? "";
|
||||
if (key === "s:price") return col.일위대가 ?? "";
|
||||
if (key === KEY_DRAWING) return drawingText(state, col.id);
|
||||
if (key === KEY_PRICE) return col.일위대가 ?? "";
|
||||
if (key === KEY_DRAWING || key === KEY_BASIS) return refText(state, key, col.id);
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
if (formula) return `=${formula}`;
|
||||
@@ -189,27 +213,48 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
return raw === null || raw === undefined ? "" : String(raw);
|
||||
};
|
||||
const write = (key: string, col: SheetColumn, text: string): void => {
|
||||
if (key === KEY_DRAWING) return; // 읽기만
|
||||
if (key === KEY_DRAWING || key === KEY_BASIS) return; // 읽기만
|
||||
const value = text.trim();
|
||||
if (key === KEY_UNIT) col.단위 = value || null;
|
||||
else if (key === "s:formula") {
|
||||
if (value) col.식 = value;
|
||||
else delete col.식;
|
||||
} else if (key === "s:desc") col.설명 = value;
|
||||
else if (key === "s:price") col.일위대가 = value || null;
|
||||
else if (key === KEY_PRICE) col.일위대가 = value || null;
|
||||
else setCell(state.doc, key.slice(2), col, text);
|
||||
changed();
|
||||
};
|
||||
/** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */
|
||||
const pick = async (key: string, colId: string): Promise<void> => {
|
||||
const got = await opts.onPick?.(selectionOf(state.doc, key, colId));
|
||||
const col = colOf(colId);
|
||||
if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got);
|
||||
root.focus({ preventScroll: true });
|
||||
};
|
||||
const edit = (initial?: string): void => {
|
||||
const sel = state.sel;
|
||||
const col = sel && colOf(sel.c);
|
||||
if (!sel || !col || !keyEditable(state, sel.r, col)) return;
|
||||
if (!sel || !col) return;
|
||||
if (opts.onPick && state.mode === "master" && PICK_ROWS.includes(sel.r)) {
|
||||
if (initial === undefined) void pick(sel.r, col.id); // 글자를 쳐서는 안 엶
|
||||
return;
|
||||
}
|
||||
if (!keyEditable(state, sel.r, col)) return;
|
||||
const td = cellsAt(sel.r, sel.c)[0];
|
||||
if (!td) return;
|
||||
startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value));
|
||||
const box = td.querySelector("input");
|
||||
if (box && initial !== undefined) box.value = initial;
|
||||
};
|
||||
/** 층 이름(머리 층 줄 머리 칸) 고치기 — master */
|
||||
const editLayer = (th: HTMLElement): void => {
|
||||
const level = Number(th.dataset.layer);
|
||||
if (Number.isNaN(level)) return;
|
||||
startEdit(th, th.textContent ?? "", (value) => {
|
||||
setLayerName(state.doc, level, value);
|
||||
changed();
|
||||
});
|
||||
};
|
||||
const editHead = (th: HTMLElement): void => {
|
||||
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
|
||||
const level = Number(th.dataset.level);
|
||||
@@ -242,7 +287,9 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input")) return;
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
const layer = target.closest<HTMLElement>("th[data-layer]");
|
||||
if (th && state.mode === "master") editHead(th);
|
||||
else if (layer && state.mode === "master") editLayer(layer);
|
||||
else if (target.closest("td[data-r]")) edit();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
|
||||
@@ -19,10 +19,14 @@ export const ROW_FIXED_ALL = "전구간";
|
||||
const PAGE_ROWS = 50;
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
export const KEY_PRICE = "s:price";
|
||||
export const KEY_DRAWING = "s:drawing";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */
|
||||
export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const;
|
||||
export const KEY_BASIS = "s:basis";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 산출근거 줄은 손으로 못 침. */
|
||||
export const MASTER_ROWS = ["s:formula", "s:desc", KEY_PRICE, KEY_DRAWING, KEY_BASIS] as const;
|
||||
export type MasterRowKey = (typeof MASTER_ROWS)[number];
|
||||
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
|
||||
export const PICK_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_BASIS];
|
||||
|
||||
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
|
||||
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
|
||||
@@ -124,6 +128,38 @@ export function addColumn(doc: SheetDoc, after: string | null, label: string): s
|
||||
return id;
|
||||
}
|
||||
|
||||
/** `after` 열과 같은 맨 위 묶음(종류) 끝에 둘째 층 새 묶음 열 — `[위, group, …null, leaf]`(층 `depth` 가
|
||||
* 3 이상일 때 · 가운데 층은 위 칸과 합침) · 새 열 id. */
|
||||
export function addGroup(
|
||||
doc: SheetDoc,
|
||||
after: string | null,
|
||||
depth: number,
|
||||
group: string,
|
||||
leaf: string,
|
||||
): string {
|
||||
const id = freshId(
|
||||
"c",
|
||||
doc.열.map((c) => c.id),
|
||||
);
|
||||
let at = doc.열.findIndex((c) => c.id === after);
|
||||
const base = at < 0 ? null : doc.열[at];
|
||||
const top = base?.머리[0] ?? group;
|
||||
if (base) while (at + 1 < doc.열.length && doc.열[at + 1].머리[0] === top) at += 1;
|
||||
const head: (string | null)[] = [top, group];
|
||||
while (head.length < depth - 1) head.push(null);
|
||||
head.push(leaf);
|
||||
const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" };
|
||||
doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */
|
||||
export function setLayerName(doc: SheetDoc, level: number, text: string): void {
|
||||
const layers = (doc.층 ??= []);
|
||||
while (layers.length <= level) layers.push("");
|
||||
layers[level] = text.trim();
|
||||
}
|
||||
|
||||
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
|
||||
export function deleteColumn(doc: SheetDoc, id: string): void {
|
||||
doc.열 = doc.열.filter((c) => c.id !== id);
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
|
||||
*
|
||||
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing|basis`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
@@ -14,10 +15,12 @@ import {
|
||||
cellEditable,
|
||||
isBoundColumn,
|
||||
isCalcColumn,
|
||||
KEY_BASIS,
|
||||
KEY_DRAWING,
|
||||
KEY_UNIT,
|
||||
MASTER_ROWS,
|
||||
type MasterRowKey,
|
||||
PICK_ROWS,
|
||||
pagePlan,
|
||||
type SheetMode,
|
||||
} from "./ui_template_sheet_ops";
|
||||
@@ -34,13 +37,17 @@ export interface RenderState {
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
/** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
/** 도면 · 산출근거 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
|
||||
drawingLabel?: (colId: string) => string;
|
||||
basisLabel?: (colId: string) => string;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
pick?: boolean;
|
||||
}
|
||||
|
||||
/** 도면 줄 칸 글. */
|
||||
export const drawingText = (state: RenderState, colId: string): string =>
|
||||
state.drawingLabel?.(colId)?.trim() || st("Drawing_None");
|
||||
/** 도면 · 산출근거 줄 칸 글. */
|
||||
export const refText = (state: RenderState, key: string, colId: string): string =>
|
||||
(key === KEY_BASIS ? state.basisLabel : state.drawingLabel)?.(colId)?.trim() ||
|
||||
st("Drawing_None");
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
@@ -55,9 +62,9 @@ function headFont(): string {
|
||||
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
}
|
||||
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 줄은 읽기만. */
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 · 산출근거 줄은 손으로 못 침. */
|
||||
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
|
||||
if (key === KEY_DRAWING) return false;
|
||||
if (key === KEY_DRAWING || key === KEY_BASIS) return false;
|
||||
if (key.startsWith("s:")) return state.mode === "master";
|
||||
if (key.startsWith("t:")) return false;
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
@@ -86,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const names = columnNames(cols);
|
||||
const font = headFont();
|
||||
const mins = headMinWidths(cols, depth, font);
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 합계 이름이 안 잘리게
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 · 합계 이름이 안 잘리게
|
||||
const GUTTER = Math.min(
|
||||
GUTTER_MAX,
|
||||
Math.max(
|
||||
@@ -98,6 +105,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
st("Row_Desc"),
|
||||
st("Row_Unit_Price"),
|
||||
st("Row_Drawing"),
|
||||
st("Row_Basis"),
|
||||
...(doc.합계줄 ?? []).map((t) => t.이름),
|
||||
].map((label) => Math.ceil(textWidth(label, font)) + 16),
|
||||
),
|
||||
@@ -165,6 +173,7 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
"s:desc": "Row_Desc",
|
||||
"s:price": "Row_Unit_Price",
|
||||
"s:drawing": "Row_Drawing",
|
||||
"s:basis": "Row_Basis",
|
||||
} as const
|
||||
)[key];
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
@@ -174,11 +183,12 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
? formulaLabel(col.식 ?? "", names, col.id)
|
||||
: key === "s:desc"
|
||||
? ""
|
||||
: key === KEY_DRAWING
|
||||
? drawingText(state, col.id)
|
||||
: key === KEY_DRAWING || key === KEY_BASIS
|
||||
? refText(state, key, col.id)
|
||||
: (col.일위대가 ?? "");
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (state.pick && PICK_ROWS.includes(key)) td.classList.add("is-pick");
|
||||
if (key === "s:formula" && col.식) {
|
||||
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
|
||||
td.textContent = "";
|
||||
@@ -209,7 +219,9 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
|
||||
const thead = (): HTMLElement => {
|
||||
const rows = head.map((cells, level) => {
|
||||
const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] });
|
||||
const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : "");
|
||||
layer.dataset.layer = String(level);
|
||||
const tr = el("tr", { children: [layer] });
|
||||
for (const cell of cells) {
|
||||
const th = el("th", {
|
||||
text: cell.label,
|
||||
|
||||
@@ -17,6 +17,10 @@ const TEXT = {
|
||||
Row_Desc: ["들어갈 것", "Source"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Row_Drawing: ["도면", "Drawing"],
|
||||
Row_Basis: ["산출근거", "Basis"],
|
||||
Group_Add: ["묶음 더하기", "Add group"],
|
||||
Group_New: ["새 분류", "New group"],
|
||||
Add_Suffix: [" 더하기", " +"],
|
||||
Drawing_None: ["없음", "None"],
|
||||
Kind_Bound: ["설계값", "Design"],
|
||||
Kind_Calc: ["계산", "Calc"],
|
||||
|
||||
Reference in New Issue
Block a user