Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-09-29 13:45:24 +09:00
12 changed files with 321 additions and 36 deletions
+10 -3
View File
@@ -31,7 +31,7 @@ import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
@@ -419,8 +419,15 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
);
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
const loadSaved = (): Promise<void> => run(() => fetchDetailPipePoints(projectId as string));
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
const loadSaved = (): Promise<void> =>
run(() => {
const pending = readPendingPipes(projectId as string);
return pending?.length
? computeDetailBasins(projectId as string, pending)
: fetchDetailPipePoints(projectId as string);
});
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
+11 -2
View File
@@ -32,6 +32,7 @@ import {
} from "./B05_Profile_UI_Structures_Panel_Events";
import {
commit as commitInto,
commitSummaryOnly,
emit as emitFrom,
loadForm as loadFormInto,
loadPipeForm as loadPipeFormInto,
@@ -358,6 +359,14 @@ export function createStructuresSection(
});
}
/** 집계 항목 지정 · 해제 반영 — 고치는 구조물이면 폼 검증 없이 기록에 바로, 그 밖은 실시간 반영. */
function commitSummary(): void {
applyingLive = true;
const done = commitSummaryOnly(panelContext);
applyingLive = false;
if (!done) liveCommit();
}
function syncButtons(): void {
primary.textContent =
tempPipeChainage !== null
@@ -478,12 +487,12 @@ export function createStructuresSection(
void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => {
if (result.action === "cancel") return;
summary.set(result.action === "apply" ? result.item : null);
liveCommit();
commitSummary();
});
},
clear: () => {
summary.set(null);
liveCommit();
commitSummary();
},
});
body.insertBefore(field("메모", memoField), actions);
@@ -190,6 +190,22 @@ export function emit(ctx: StructuresCommitContext): void {
ctx.callbacks.onChange([...ctx.structures]);
}
/** 집계 항목만 바뀐 때 — 폼 검증(필수 옵션 · 측점)에 걸려 조용히 버려지지 않게, 고치는 중인
* 구조물 기록에 곧바로 반영한다(PLAN 21-10 흠 2). 반영했으면 true — 관 · 새 항목은 false 라
* 종전 흐름(`commit(true)` · [추가])을 따른다. */
export function commitSummaryOnly(ctx: StructuresCommitContext): boolean {
const id = ctx.editingId();
const index = id ? ctx.structures.findIndex((entry) => entry.structure_id === id) : -1;
if (index < 0 || ctx.tempPipeChainage() !== null) return false;
const next: StructureInstance = { ...ctx.structures[index] };
const summary = ctx.summary();
if (summary) next.summary_item = summary;
else delete next.summary_item;
ctx.structures[index] = next;
emit(ctx);
return true;
}
export async function commit(ctx: StructuresCommitContext, live = false): Promise<void> {
const type = ctx.currentType();
if (!type) return;
@@ -91,6 +91,20 @@ def _clean(row: Any, key: str, keep: dict | None = None) -> dict[str, Any]:
}
def _short(name: str, at: int = 30) -> str:
return name if len(name) <= at else name[:at] + "…"
def _check_name(rows: list, key: str, name: str) -> None:
"""이름 겹침 · 긴 이름 — 틀 검사(파일명 · 키가 섞인 글) 앞에서 쉬운 말 한 줄로 막음."""
if len(name) > mmc.MAX_이름:
raise store.StoreError(422, {"errors": [f"이름이 너무 김 — {mmc.MAX_이름}자까지"]})
if any(r.get("키") != key and str(r.get("이름", "")).strip() == name for r in rows):
raise store.StoreError(
422, {"errors": [f"「{_short(name)}」 이름의 자재 조합이 이미 있음 — 다른 이름으로"]}
)
def _save(doc: dict, version: str) -> str:
"""틀 검사 → 판이 같을 때만 씀 → 새 판."""
if errors := mmc.check_form(FILE, doc):
@@ -111,6 +125,7 @@ def create(row: Any, version: str | None = None) -> dict[str, Any]:
fixed = _clean(row, key)
if fixed["본뜬키"] and fixed["본뜬키"] not in {r.get("키") for r in doc["줄"]}:
raise store.StoreError(400, f"본뜬키 「{fixed['본뜬키']}」 — 있는 다른 자재 조합 키여야 함")
_check_name(doc["줄"], key, fixed["이름"])
doc = {**doc, "다음": int(doc.get("다음") or 1) + 1, "줄": [*doc["줄"], fixed]}
return {"키": key, "판": _save(doc, have if version is None else version), "줄": fixed}
@@ -118,6 +133,7 @@ def create(row: Any, version: str | None = None) -> dict[str, Any]:
def edit(key: str, row: Any, version: str) -> dict[str, Any]:
doc, _ = _load()
fixed = _clean(row, key, keep=_find(doc, key))
_check_name(doc["줄"], key, fixed["이름"])
doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]}
return {"키": key, "판": _save(doc, version), "줄": fixed}
@@ -354,6 +354,7 @@ export function createMain(
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
editor = create(host, doc, {
mode: at.layer === "project" ? "project" : "master",
readOnly,
onChange: () => (dirty = !readOnly),
...(struct
? {
@@ -49,7 +49,11 @@ class FakeNode {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k));
kids.forEach((k) =>
this.children.push(
typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k,
),
);
}
replaceChildren(...kids) {
this.children = [];
@@ -72,7 +76,13 @@ globalThis.Option = function Option(text, value) {
};
globalThis.requestAnimationFrame = (fn) => fn();
const session = new Map();
globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) } };
globalThis.window = {
sessionStorage: {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
},
};
globalThis.sessionStorage = globalThis.window.sessionStorage;
const STUBS = {
@@ -110,38 +120,85 @@ const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options");
const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit");
const out = {};
const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m" };
const A = {
layer: "personal",
owner: "삼성/홍길동",
key: "SS000007",
name: "돌기슭막이 메쌓기 1.2m",
};
const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" };
// ── 순수 규칙 ───────────────────────────────────────────────────────────
out.visible = [logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false })];
const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" } };
out.visible = [
logic.isOptionVisible({}),
logic.isOptionVisible({ visible: true }),
logic.isOptionVisible({ visible: false }),
];
const before = {
type_id: "wall",
options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" },
};
out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 });
out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {});
out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 });
out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 });
out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 });
out.isItem = [item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" })];
out.isItem = [
item.isSummaryItem(A),
item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }),
item.isSummaryItem(null),
item.isSummaryItem({ ...A, key: "" }),
];
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
out.same = [item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null)];
out.same = [
item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }),
item.sameSummaryItem(A, B),
item.sameSummaryItem(null, null),
item.sameSummaryItem(A, null),
];
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
// 관 계열 merge — 집계 항목 이어받기 · 지우기
const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A };
out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }).summary_item ?? null;
out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null }).summary_item;
out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B }).summary_item.key;
out.pipeKeep =
merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } })
.summary_item ?? null;
out.pipeClear = merge.mergeFacilityOptions(pipePrev, {
facility: "pipe",
options: {},
summary_item: null,
}).summary_item;
out.pipeSet = merge.mergeFacilityOptions(pipePrev, {
facility: "pipe",
options: {},
summary_item: B,
}).summary_item.key;
// 집계 항목 줄
const row = buildSummaryRow();
const clicks = [];
row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") });
const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children;
out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent };
out.rowEmpty = {
name: nameNode.textContent,
assigned: nameNode.classList.contains("is-assigned"),
clearDisabled: clearBtn.disabled,
get: row.get(),
label: labelNode.textContent,
pick: pickBtn.textContent,
clear: clearBtn.textContent,
};
row.set(A);
out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007") };
out.rowSet = {
name: nameNode.textContent,
assigned: nameNode.classList.contains("is-assigned"),
clearDisabled: clearBtn.disabled,
title: nameNode.title,
key: row.get().key,
nameShowsKey: nameNode.textContent.includes("SS000007"),
};
pickBtn.click();
clearBtn.click();
out.clicks = clicks;
@@ -150,11 +207,22 @@ out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled
// 목록 줄 — 지정 색 · 툴팁
const list = new FakeNode("ul");
const types = new Map([["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }]]);
const types = new Map([
["wall", { name: "옹벽" }],
["fence", { name: "울타리" }],
["pipe", { name: "배수관" }],
]);
renderStructureList({
list,
structures: [
{ structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A },
{
structure_id: "s1",
type_id: "wall",
placement: "point",
chainage_m: 100,
options: {},
summary_item: A,
},
{ structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} },
],
pipeFacilities: [
@@ -168,10 +236,21 @@ renderStructureList({
onSelectStructure() {},
onSelectPipe() {},
});
out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title }));
out.list = list.children.map((li) => ({
assigned: li.classList.contains("is-assigned"),
title: li.title,
}));
// 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로
const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra });
const opt = (key, extra = {}) => ({
key,
label: key,
input: "number",
choices: [],
unit: null,
default: null,
...extra,
});
const wallType = {
type_id: "wall",
options: [
@@ -206,7 +285,13 @@ out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로
// [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림
const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] };
async function saveWith({ previous, summary, formOptions }) {
async function saveWith({
previous,
summary,
formOptions,
requiredEmpty = false,
summaryOnly = false,
}) {
const structures = previous ? [previous] : [];
let summaryValue = summary;
let changed = null;
@@ -221,10 +306,10 @@ async function saveWith({ previous, summary, formOptions }) {
facilityOptions: {},
optionInputs: Object.entries(formOptions).map(([key, value]) => ({
key,
required: false,
input: {},
required: requiredEmpty,
input: { focus() {} },
read: () => value,
isEmpty: () => false,
isEmpty: () => requiredEmpty,
})),
pipeFacilities: () => [],
interval: () => 20,
@@ -252,8 +337,12 @@ async function saveWith({ previous, summary, formOptions }) {
summary: () => summaryValue,
setSummary: (value) => (summaryValue = value),
};
if (summaryOnly) {
const done = commitModule.commitSummaryOnly(ctx);
return { done, record: changed ? changed[0] : null };
}
await commitModule.commit(ctx, true);
return changed[0];
return changed ? changed[0] : null;
}
const prev = () => ({
structure_id: "s1",
@@ -278,6 +367,41 @@ async function main() {
out.commitKept = { key: kept.summary_item?.key, options: kept.options };
const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } });
out.commitSwapped = swapped.summary_item?.key;
// 집계 항목만 바뀐 때(PLAN 21-10 흠 2) — 필수 옵션이 비어 폼 검증에 걸려도 기록에 바로 반영
const blocked = await saveWith({
previous: prev(),
summary: B,
formOptions: { H: 3 },
requiredEmpty: true,
});
out.formBlocks = blocked === null; // 종전 흐름(commit)은 필수 옵션이 비면 아무것도 안 함
const only = await saveWith({
previous: prev(),
summary: B,
formOptions: { H: 3 },
requiredEmpty: true,
summaryOnly: true,
});
out.summaryOnlyAssign = {
done: only.done,
key: only.record?.summary_item?.key,
options: only.record?.options,
};
const onlyClear = await saveWith({
previous: prev(),
summary: null,
formOptions: { H: 3 },
requiredEmpty: true,
summaryOnly: true,
});
out.summaryOnlyClear = { done: onlyClear.done, hasKey: "summary_item" in onlyClear.record };
const onlyNew = await saveWith({
previous: null,
summary: B,
formOptions: { H: 3 },
summaryOnly: true,
});
out.summaryOnlyNew = onlyNew.done; // 새 항목은 [추가] 몫 — 반영 안 함
process.stdout.write(JSON.stringify(out));
}
main();
@@ -112,3 +112,27 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐(
"options": {"hidden_cost_key": "keep", "detail_only": 9, "H": 3},
}
assert out["commitSwapped"] == "SS000004"
def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out):
# 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤
assert out["formBlocks"] is True
# 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로
assert out["summaryOnlyAssign"] == {
"done": True,
"key": "SS000004",
"options": {"H": 2, "hidden_cost_key": "keep", "detail_only": 9},
}
assert out["summaryOnlyClear"] == {"done": True, "hasKey": False}
assert out["summaryOnlyNew"] is False
def test_배수유역_화면은_들어올_때_세션_초안_관을_먼저_씀():
# 흠 3 — B05→B06→B05 를 오가도 저장 전 지정 · 옵션이 옛값으로 안 돌아감
source = open(
os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"),
encoding="utf-8",
).read()
body = source.split("const loadSaved", 1)[1].split("/**", 1)[0]
assert "readPendingPipes(" in body and "computeDetailBasins(" in body
assert body.index("readPendingPipes(") < body.index("fetchDetailPipePoints(")
+40
View File
@@ -109,3 +109,43 @@ def test_읽기만_회사용_개인용으로_복사_막대():
assert "titleOf(layer)" in page
layer = _text(LAYER)
assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer
SHEET = ROOT / "ui_template" / "sheet"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_열_0_첫_열_만들기():
"""빈 개인 · 회사 층 — 열이 하나도 없어도 첫 열(머리 글 + 빈 아래 층)을 만듦."""
ops = _text(SHEET / "ui_template_sheet_ops.ts")
fresh = re.search(r"^function freshId[\s\S]*?^}", ops, re.M)
first = re.search(r"^export function addFirstColumn[\s\S]*?^}", ops, re.M)
assert fresh and first, "freshId · addFirstColumn 을 못 찾음"
script = (
"interface SheetColumn { id: string; 머리: (string | null)[]; 단위?: string | null;"
" 꼴?: string; 일위대가?: string | null; 자재조합?: string | null }\n"
"interface SheetDoc { 열: SheetColumn[] }\n"
+ fresh.group(0)
+ "\n"
+ first.group(0).replace("export ", "")
+ "\nconst doc: SheetDoc = { 열: [] };"
+ "\nconst a = addFirstColumn(doc, 4, '새 열');"
+ "\nconsole.log(JSON.stringify([a, doc.열[0].머리, doc.열[0].꼴, doc.열.length]));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["c1", ["새 열", "", "", ""], "수", 1]
def test_읽기만_표는_단추_고치기_모두_막음():
sheet = _text(SHEET / "ui_template_sheet.ts")
assert "const ro = !!opts.readOnly;" in sheet
assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7
# 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열
assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet
assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts"))
assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css")
+9 -1
View File
@@ -83,7 +83,15 @@ def test_없는_자재_두_번_이름_본뜬키는_막는다(client) -> None:
ok = _new(client, "원본", [M1]).json()["키"]
copy = _new(client, "본뜬 것", [M1, M2], 본뜬키=ok).json()
assert copy["줄"]["본뜬키"] == ok and copy["키"] != ok
assert _new(client, "원본", [M2]).status_code == 422 # 이름 겹침
dup = _new(client, "원본", [M2]) # 이름 겹침 — 파일명 · 키 없이 쉬운 말 한 줄
assert dup.status_code == 422 and dup.json()["detail"]["errors"] == [
"「원본」 이름의 자재 조합이 이미 있음 — 다른 이름으로"
]
long = "긴" * 40
assert _new(client, long, [M1]).status_code == 200
cut = _new(client, long, [M2]).json()["detail"]["errors"][0]
assert cut.startswith("「" + "긴" * 30 + "…」") and matset.FILE not in cut and "UM0" not in cut
assert _new(client, "긴" * 61, [M1]).json()["detail"]["errors"] == ["이름이 너무 김 — 60자까지"]
assert _new(client, "낡은 판", [M2], 판="0000").status_code == 409
+5
View File
@@ -181,3 +181,8 @@
font: inherit;
text-align: inherit;
}
/* 읽기만 표 — 단추 줄 없음(`display:flex` 가 hidden 을 덮지 않게) */
.ui-sheet__toolbar[hidden] {
display: none;
}
+32 -9
View File
@@ -8,6 +8,8 @@
* 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은
* 치지 않고 조회 후 선택.
* `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 ·
* 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기).
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
@@ -17,6 +19,7 @@
import { createButton, el } from "@ui/ui_template_elements";
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
import {
addFirstColumn,
addLevel,
deleteColumn,
isBlankPick,
@@ -44,6 +47,8 @@ export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_
export interface SheetOptions {
mode: SheetMode;
/** 볼 수만 있음(단추 · 고치기 없음) */
readOnly?: boolean;
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
@@ -80,12 +85,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
materialLabel: opts.materialLabel,
pick: !!opts.onPick,
};
const ro = !!opts.readOnly;
const scroll = el("div", { className: "ui-sheet__scroll" });
const toolbar = el("div", { className: "ui-sheet__toolbar" });
const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} });
const root = el("div", {
className: `ui-sheet ui-sheet--${opts.mode}`,
attrs: { tabindex: "0" },
children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll],
children: [
toolbar,
...(ro ? [] : [el("p", { className: "ui-sheet__hint", text: st("Hint") })]),
scroll,
],
});
host.replaceChildren(root);
@@ -96,6 +106,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
createButton({ label, variant: "ghost", onClick: run });
/** `level` 층 새 칸 열 — 고른 열 무리 끝에(층 이름이 있으면 「새 분류2」 · 없으면 「새 열」) */
const addAt = (level: number): void => {
if (!state.doc.열.length) return void addFirst();
const at = state.sel?.c;
if (!at) return;
const name = state.doc.층?.[level]?.trim();
@@ -105,6 +116,14 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
changed();
};
/** 열 0 — 첫 열을 만들고 머리 글을 바로 고치게 엶(종류/분류 는 `/` 로 이음) */
const addFirst = (): void => {
const id = addFirstColumn(state.doc, headDepth(state.doc.열, state.doc.층), st("Col_New"));
state.sel = { r: KEY_UNIT, c: id };
changed();
const th = scroll.querySelector<HTMLElement>(`th[data-col="${CSS.escape(id)}"]`);
if (th) editHead(th);
};
const colDelete = button(st("Col_Delete"), () => {
const id = state.sel?.c;
if (!id || state.doc.열.length <= 1) return;
@@ -114,7 +133,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
changed();
});
if (opts.mode === "master") toolbar.append(colDelete);
if (opts.mode === "master" && !ro) toolbar.append(colDelete);
// 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 ·
// 층 이름 · 층 수가 바뀌면 다시 만듦
@@ -122,8 +141,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
let levelKey = "";
const syncLevels = (hasCol: boolean): void => {
const depth = headDepth(state.doc.열, state.doc.층);
const levels = depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1);
const empty = !state.doc.열.length; // 열 0 = 「열 더하기」 하나만(첫 열)
const levels = empty || depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1);
const labels = levels.map((level) => {
if (empty) return st("Col_Add");
const name = state.doc.층?.[level]?.trim();
return name ? `${name}${st("Add_Suffix")}` : st("Col_Add");
});
@@ -133,12 +154,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
levelButtons = levels.map((level, i) => button(labels[i], () => addAt(level)));
colDelete.before(...levelButtons);
}
for (const b of levelButtons) b.disabled = !hasCol;
for (const b of levelButtons) b.disabled = !hasCol && !empty;
};
const syncToolbar = (): void => {
const col = state.sel ? colOf(state.sel.c) : undefined;
if (opts.mode === "master") syncLevels(!!col);
if (opts.mode === "master" && !ro) syncLevels(!!col);
colDelete.disabled = !col || state.doc.열.length <= 1;
};
@@ -197,7 +218,7 @@ 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 (REF_ROWS.includes(key)) return; // 읽기만
if (ro || REF_ROWS.includes(key)) return; // 읽기만
const value = text.trim();
if (key === KEY_UNIT) col.단위 = value || null;
else if (key === KEY_PRICE) col.일위대가 = value || null;
@@ -213,6 +234,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
};
/** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */
const edit = (initial?: string, atEnd = false): void => {
if (ro) return;
const sel = state.sel;
const col = sel && colOf(sel.c);
if (!sel || !col) return;
@@ -272,6 +294,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
if (target.closest("input")) return;
const th = target.closest<HTMLElement>("th[data-col]");
const layer = target.closest<HTMLElement>("th[data-layer]");
if (ro) return;
if (th && state.mode === "master") editHead(th);
else if (layer && state.mode === "master") editLayer(layer);
else if (target.closest("td[data-r]")) edit();
@@ -310,7 +333,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
edit(undefined, true);
} else if (event.key === "Delete" || event.key === "Backspace") {
const col = state.sel && colOf(state.sel.c);
if (state.sel && col && keyEditable(state, state.sel.r, col)) {
if (!ro && state.sel && col && keyEditable(state, state.sel.r, col)) {
event.preventDefault();
write(state.sel.r, col, "");
}
@@ -325,7 +348,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
const onPointerDown = (event: PointerEvent): void => {
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-resize]");
const col = handle && colOf(handle.dataset.resize!);
if (!handle || !col) return;
if (ro || !handle || !col) return;
event.preventDefault();
handle.setPointerCapture(event.pointerId);
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
@@ -133,6 +133,18 @@ export function addLevel(
return id;
}
/** 열이 하나도 없는 표의 첫 열 — `[label, "" …]`(아래 층은 빈칸) · 새 열 id. */
export function addFirstColumn(doc: SheetDoc, depth: number, label: string): string {
const id = freshId(
"c",
doc.열.map((c) => c.id),
);
const head: (string | null)[] = [label];
while (head.length < depth) head.push("");
doc.열.push({ id, 머리: head, 단위: null, 꼴: "수", 일위대가: null, 자재조합: null });
return id;
}
/** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */
export function setLayerName(doc: SheetDoc, level: number, text: string): void {
const layers = (doc.층 ??= []);