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
+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.층 ??= []);