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

This commit is contained in:
2026-09-28 10:56:08 +09:00
19 changed files with 661 additions and 1272 deletions
+42 -42
View File
@@ -7,8 +7,8 @@
height: 100%;
min-height: 0;
font-size: 13px;
color: #1f2328;
background: #fff;
color: var(--color-text-body);
background: var(--color-surface-raised);
}
.ss-grid-host {
@@ -21,26 +21,26 @@
flex: none;
align-items: stretch;
height: 26px;
border-bottom: 1px solid #d0d7de;
border-bottom: 1px solid var(--color-border);
}
.ss-addr {
width: 96px;
padding: 0 6px;
border: none;
border-right: 1px solid #d0d7de;
border-right: 1px solid var(--color-border);
font: inherit;
outline: none;
}
.ss-addr:focus {
box-shadow: inset 0 0 0 2px #217346;
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss-fx {
align-self: center;
padding: 0 8px;
color: #6e7781;
color: var(--color-text-muted);
font-style: italic;
user-select: none;
}
@@ -49,7 +49,7 @@
flex: 1;
padding: 4px 6px;
border: none;
border-left: 1px solid #d0d7de;
border-left: 1px solid var(--color-border);
font: inherit;
line-height: 18px;
white-space: pre;
@@ -59,7 +59,7 @@
}
.ss--readonly .ss-formula {
color: #57606a;
color: var(--color-text-secondary);
}
/* 칸 편집기 = 숨은 textarea — 쉴 때는 활성 칸 자리에 투명하게(한글 조합 창 자리) */
@@ -69,9 +69,9 @@
box-sizing: border-box;
margin: 0;
padding: 1px 3px;
border: 2px solid #217346;
background: #fff;
color: #000;
border: 2px solid var(--color-focus-ring);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
line-height: 1.25;
white-space: pre;
@@ -95,7 +95,7 @@
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 1px dashed #57606a;
border: 1px dashed var(--color-text-secondary);
pointer-events: none;
}
@@ -106,8 +106,8 @@
z-index: 20;
padding: 6px 10px;
border-radius: 4px;
background: #24292f;
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
}
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
@@ -115,15 +115,15 @@
flex: none;
padding: 0 8px;
border: none;
border-right: 1px solid #d0d7de;
border-right: 1px solid var(--color-border);
background: none;
font: inherit;
cursor: pointer;
}
.ss-brush.is-on {
background: #dafbe1;
box-shadow: inset 0 0 0 2px #217346;
background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised));
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.ss--painting .ss-grid-host {
@@ -139,10 +139,10 @@
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid #d0d7de;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-find[hidden],
@@ -162,15 +162,15 @@
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid #d0d7de;
border: 1px solid var(--color-border);
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid #d0d7de;
border: 1px solid var(--color-border);
border-radius: 4px;
background: #f6f8fa;
background: var(--color-surface);
font: inherit;
cursor: pointer;
}
@@ -184,7 +184,7 @@
.ss-find__status {
font-size: 12px;
color: #57606a;
color: var(--color-text-secondary);
}
.ss-memo,
@@ -194,11 +194,11 @@
min-width: 140px;
max-width: 260px;
padding: 4px 6px;
border: 1px solid #bf8700;
background: #fff8c5;
border: 1px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface-raised));
font: inherit;
white-space: pre-wrap;
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
box-shadow: var(--shadow-sm);
}
.ss-memo {
@@ -213,15 +213,15 @@
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid #d0d7de;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid #d0d7de;
background: #f6f8fa;
border: 1px solid var(--color-border);
background: var(--color-surface);
font: inherit;
cursor: pointer;
}
@@ -232,10 +232,10 @@
z-index: 6;
min-width: 200px;
max-width: 360px;
border: 1px solid #d0d7de;
background: #fff;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
font-size: 12px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
box-shadow: var(--shadow-lg);
}
.ss-hint[hidden] {
@@ -255,7 +255,7 @@
}
.ss-hint__item.is-on {
background: #ddf4ff;
background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised));
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
@@ -266,10 +266,10 @@
z-index: 21;
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid #d0d7de;
border: 1px solid var(--color-border);
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-dialog[hidden],
@@ -283,7 +283,7 @@
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #d0d7de;
border-bottom: 1px solid var(--color-border);
font-weight: bold;
}
@@ -296,8 +296,8 @@
z-index: 8;
width: 16px;
padding: 0;
border: 1px solid #8c959f;
background: #f6f8fa;
border: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 9px;
cursor: pointer;
}
@@ -94,7 +94,7 @@ export function resolveBoxBorders(
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). `scale` = 확대 · 축소 배율(선 굵기도 같이). */
function borderCss(side: BorderSide | undefined, scale: number): string {
if (!side) return "";
const color = side.색 ?? "#000000";
const color = side.색 ?? "currentColor";
const w = (px: number): number => Math.max(1, Math.round(px * scale));
switch (side.선) {
case "hair":
+15 -10
View File
@@ -4,12 +4,16 @@
* ========================================================================== */
.aislo-grid {
--aislo-grid-line: #d9d9d9;
--aislo-grid-head-bg: #f3f3f3;
--aislo-grid-head-fg: #444;
--aislo-grid-head-border: #cfcfcf;
/* 앱 전역 테마 토큰(ui_template_theme.css · :root[data-theme])을 그대로 물려받음 —
* 여기서 값을 다시 안 적음(다크 전환 시 앱 토큰이 바뀌면 같이 바뀜). */
--aislo-grid-bg: var(--color-surface-raised);
--aislo-grid-fg: var(--color-text-body);
--aislo-grid-line: var(--color-border);
--aislo-grid-head-bg: var(--color-surface);
--aislo-grid-head-fg: var(--color-text-secondary);
--aislo-grid-head-border: var(--color-border);
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
--aislo-grid-selected: #1a73e8;
--aislo-grid-selected: var(--color-accent);
position: relative;
overflow: hidden;
@@ -18,7 +22,8 @@
font:
10pt "굴림",
sans-serif;
background: #fff;
background: var(--aislo-grid-bg);
color: var(--aislo-grid-fg);
user-select: none;
-webkit-user-select: none;
}
@@ -94,7 +99,7 @@
.aislo-grid__colhead--hidden-before,
.aislo-grid__rowhead--hidden-before {
border-left: 3px double #888;
border-left: 3px double var(--aislo-grid-head-fg);
}
.aislo-grid__head-label {
@@ -122,7 +127,7 @@
box-sizing: border-box;
padding: 0;
border: 1px solid var(--aislo-grid-head-border);
background: #fff;
background: var(--aislo-grid-head-bg);
color: var(--aislo-grid-head-fg);
font-size: 0.7em;
line-height: 1;
@@ -160,7 +165,7 @@
flex-direction: column;
overflow: hidden;
padding: 0 2px;
background: #fff;
background: var(--aislo-grid-bg);
cursor: cell;
}
@@ -228,7 +233,7 @@
width: 6px;
height: 6px;
background: var(--aislo-grid-selected);
border: 1px solid #fff;
border: 1px solid var(--aislo-grid-bg);
cursor: crosshair;
z-index: 3;
}
+1 -1
View File
@@ -815,7 +815,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
node.style.top = `${screen.y}px`;
node.style.width = `${screen.w}px`;
node.style.height = `${screen.h}px`;
node.style.borderColor = "#1a73e8";
node.style.borderColor = "var(--aislo-grid-selected)";
node.style.borderWidth = i === 0 ? "2px" : "1px";
selLayer!.append(node);
}
@@ -75,7 +75,7 @@
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: var(--shadow-sm);
max-height: 160px;
overflow-y: auto;
}
@@ -78,3 +78,37 @@
padding: var(--spacing-16);
color: var(--color-text-muted);
}
/* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */
.m02-pick__list {
display: flex;
flex-direction: column;
max-height: 55vh;
overflow: auto;
margin: var(--spacing-8) 0;
}
.m02-pick__item {
display: flex;
gap: var(--spacing-12);
padding: var(--spacing-8);
border: none;
border-radius: var(--radius-sm, 4px);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-pick__item:hover,
.m02-pick__item:focus-visible {
background: var(--color-surface-sunken);
}
.m02-pick__key {
flex: none;
min-width: 6rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
@@ -9,13 +9,25 @@
* 구조물 도면은 집계표 열마다 하나(`resources/master_template/structure/<열 id>.json`) ·
* 서버가 열마다 미리 만들어 둠(도번 자동 · 새 열도 집계표 저장 때 서버가 같이 만듦 — `M02_MasterTemplete_Store.py`).
* 이 칸은 표 부품 host 밖 형제 자리에 붙음 — 집계표는 그대로 두고 여기만 열마다 다시 그림.
* 집계표 일위대가 · 도면 · 산출근거 줄의 조회 후 선택(`pick`)도 여기 — 일위대가 = M01 일위대가 목록에서 고름 ·
* 도면 · 산출근거 = 다른 열 것을 골라 내용만 복사해 가져옴(번호 · 열은 그대로 · 서버가 복사).
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import {
createButton,
createInputField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { openModal } from "@ui/ui_template_modal";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { type ComboSummary, fetchCombos } from "../M01_MasterData/M01_MasterData_UI_Combo_Api";
import {
copyBasis,
copyStructure,
fetchStructureNumbers,
readTemplate,
type StructureDoc,
@@ -42,6 +54,20 @@ const TEXT = {
noBasis: "산출근거 없음",
noSheet: "스프레드시트를 못 띄움 — {value}",
readFailed: "구조물 도면을 못 읽음 — {value}",
close: "닫기",
find: "찾기",
clear: "비움",
none: "고를 것이 없음",
pickPrice: "일위대가 고르기",
pickDrawing: "도면 복사해 가져오기",
pickBasis: "산출근거 복사해 가져오기",
combosFailed: "일위대가 목록을 못 받음 — {value}",
saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김",
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
drawing: "도면",
basisWord: "산출근거",
copied: "복사해 가져옴",
copyFailed: "복사 실패 — {value}",
};
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
@@ -49,6 +75,93 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** 스프레드시트는 산출근거 카드를 열 때만 받음 */
const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet");
/** `{열 id: {도번, 표번}}` — 집계표 도면 · 산출근거 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */
let combos: Promise<ComboSummary[]> | null = null;
const loadCombos = (): Promise<ComboSummary[]> =>
(combos ??= fetchCombos().catch((error: unknown) => {
combos = null;
throw error;
}));
interface Choice {
value: string;
key: string;
label: string;
}
/** 찾기 칸 + 목록 창 — 고른 값(「비움」 = 빈 글) · 그냥 닫으면 null */
function choose(title: string, items: Choice[], clearable: boolean): Promise<string | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: string | null): void => {
if (!done) resolve(value);
done = true;
};
openModal({
title,
closeLabel: TEXT.close,
dialogClass: "m02-modal m02-pick",
mount: (body, close) => {
const search = createInputField({ placeholder: TEXT.find, type: "search" });
const list = el("div", { className: "m02-pick__list" });
const paint = (): void => {
const words = search.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const hits = items.filter((it) =>
words.every((w) => `${it.key} ${it.label}`.toLowerCase().includes(w)),
);
list.replaceChildren(
...(hits.length
? hits.map((it) =>
el("button", {
className: "m02-pick__item",
attrs: { type: "button", "data-value": it.value },
children: [el("span", { className: "m02-pick__key", text: it.key }), it.label],
}),
)
: [el("p", { className: "m02-struct__empty", text: TEXT.none })]),
);
};
list.addEventListener("click", (event) => {
const item = (event.target as HTMLElement).closest<HTMLElement>("[data-value]");
if (!item) return;
finish(item.dataset.value!);
close();
});
search.input.addEventListener("input", paint);
search.input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
list.querySelector<HTMLElement>("[data-value]")?.click(); // 첫 줄
});
const foot = clearable
? [
createButton({
label: TEXT.clear,
variant: "ghost",
onClick: () => {
finish("");
close();
},
}),
]
: [];
body.append(search.root, list, ...foot);
paint();
search.input.focus();
},
});
// 닫기 단추 · Esc · 바깥 누르기 — 창이 빠지면 null
const watch = new MutationObserver(() => {
if (document.querySelector(".m02-pick")) return;
watch.disconnect();
finish(null);
});
watch.observe(document.body, { childList: true });
});
}
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2) · 꼴만 씀 */
export interface StructureColumn {
id: string;
@@ -79,17 +192,21 @@ export interface StructurePanelOptions {
export interface StructurePanelHandle {
root: HTMLElement;
/** `{열 id: 도번}` — 집계표 `drawingLabel` 이 씀 */
numbers: () => Record<string, string>;
/** 도번 목록 다시 받기 */
/** `{열 id: {도번, 표번}}` — 집계표 `drawingLabel` · `basisLabel` 이 씀 */
numbers: () => ColumnNumbers;
/** 도번 · 표번 목록 다시 받기 */
reload: () => Promise<void>;
/** 고른 열이 바뀜 — 같은 열이면 아무것도 안 함(CAD 를 다시 안 띄움) */
show: (colId: string | null, columns: StructureColumn[]) => void;
/** 집계표 조회 후 선택(`onPick`) — 일위대가 = 고른 키(「비움」 = 빈 글 · 닫으면 null) ·
* 도면 · 산출근거 = 다른 열 것을 복사해 가져옴(돌려주는 글 없음) */
pick: (row: string, colId: string | null, columns: StructureColumn[]) => Promise<string | null>;
destroy: () => void;
}
export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle {
let numbers: Record<string, StructureNumbers> = {};
let numbers: ColumnNumbers = {};
let shown: StructureColumn | null = null;
let column: string | null = null;
let columnLabel = "";
let doc: StructureDoc | null = null;
@@ -173,7 +290,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
drop();
number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber;
edit.hidden = !opts.isAdmin;
basisNo.textContent = basisNumber || TEXT.noTableNumber;
basisNo.textContent = basisNumber || numbers[column ?? ""]?.표번 || TEXT.noTableNumber;
editBasis.hidden = !opts.isAdmin;
basisHost.replaceChildren();
void paintBasis(mine);
@@ -195,6 +312,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
async function load(col: StructureColumn | null): Promise<void> {
const mine = ++seq;
drop();
shown = col;
if (!col) {
root.hidden = true;
column = null;
@@ -233,6 +351,72 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
void load(col);
};
const pickPrice = async (colId: string, columns: StructureColumn[]): Promise<string | null> => {
let list: ComboSummary[];
try {
list = await loadCombos();
} catch (error) {
showToast(fill(TEXT.combosFailed, why(error)), "error");
return null;
}
const col = columns.find((c) => c.id === colId);
const items = list.map((c) => ({
value: c.키,
key: c.키,
// 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌
label: [c.이름, c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, c.구분]
.filter(Boolean)
.join(" "),
}));
return choose(col ? `${TEXT.pickPrice} — ${columnHead(col)}` : TEXT.pickPrice, items, true);
};
/** 다른 열의 도면 · 산출근거를 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */
const pickCopy = async (
kind: "structure" | "basis",
colId: string,
columns: StructureColumn[],
): Promise<void> => {
const field = kind === "structure" ? "도번" : "표번";
const to = columns.find((c) => c.id === colId);
const toNo = numbers[colId]?.[field];
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
const items = columns
.filter((c) => c.id !== colId && numbers[c.id]?.[field])
.map((c) => ({ value: c.id, key: numbers[c.id][field]!, label: columnHead(c) }));
const from = await choose(
`${kind === "structure" ? TEXT.pickDrawing : TEXT.pickBasis} — ${toNo} ${columnHead(to)}`,
items,
false,
);
const src = columns.find((c) => c.id === from);
if (!from || !src) return;
const ask = TEXT.overwrite
.replace("{from}", `${numbers[from][field]} ${columnHead(src)}`)
.replace("{what}", kind === "structure" ? TEXT.drawing : TEXT.basisWord)
.replace("{to}", `${toNo} ${columnHead(to)}`);
if (!(await showConfirmDialog(ask))) return;
try {
await (kind === "structure" ? copyStructure : copyBasis)(colId, from);
showToast(TEXT.copied, "success");
if (column === colId) void load(shown);
} catch (error) {
showToast(fill(TEXT.copyFailed, why(error)), "error");
}
};
const pick = async (
row: string,
colId: string | null,
columns: StructureColumn[],
): Promise<string | null> => {
if (!opts.isAdmin || !colId) return null;
if (row === "s:price") return pickPrice(colId, columns);
if (row === "s:drawing" || row === "s:basis")
await pickCopy(row === "s:drawing" ? "structure" : "basis", colId, columns);
return null;
};
edit.addEventListener("click", async () => {
const at = column;
if (!at) return;
@@ -249,11 +433,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
return {
root,
numbers: () => Object.fromEntries(Object.entries(numbers).map(([id, n]) => [id, n.도번])),
numbers: () => numbers,
reload: async () => {
numbers = await fetchStructureNumbers().catch(() => ({}));
},
show,
pick,
destroy: () => {
seq += 1;
drop();
@@ -161,22 +161,30 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
"createSheet",
);
if (!create) return empty(L("M02_NoSheet"));
// 구조물집계표(시스템 층)만 — 도면 줄에 도번 · 칸을 고르면 아래 컨테이너
// 구조물집계표(시스템 층)만 — 도면 · 산출근거 줄에 도번 · 표번 · 칸을 고르면 아래 컨테이너 ·
// 고칠 수 있으면 일위대가 · 도면 · 산출근거 칸은 조회 후 선택
const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null;
if (struct) await struct.reload();
panel = struct;
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
// 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
editor = create(host, doc, {
mode: at.layer === "project" ? "project" : "master",
onChange: () => (dirty = !readOnly),
...(struct
? {
drawingLabel: (colId: string) => struct.numbers()[colId] ?? "",
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
// 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
onSelect: (s: { colId: string | null }) =>
struct.show(s.colId, sheet?.getDoc()?.열 ?? []),
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
basisLabel: (colId: string) => struct.numbers()[colId]?.표번 ?? "",
onSelect: (s: { colId: string | null }) => struct.show(s.colId, columns()),
...(readOnly
? {}
: {
onPick: (s: { row: string; colId: string | null }) =>
struct.pick(s.row, s.colId, columns()),
}),
}
: {}),
});
+3 -43
View File
@@ -3,55 +3,15 @@
"판": 1,
"열": "c1",
"서식": [
{},
{
"굵게": true,
"가로": "center",
"세로": "center",
"테두리": {
"위": {
"선": "thin"
},
"아래": {
"선": "thin"
},
"왼": {
"선": "thin"
},
"오른": {
"선": "thin"
}
}
}
{}
],
"시트": [
{
"id": "s1",
"이름": "산출근거",
"칸": {
"A1": {
"값": "공종",
"서식": 1
},
"B1": {
"값": "규격",
"서식": 1
},
"C1": {
"값": "산출 내역",
"서식": 1
},
"D1": {
"값": "단위",
"서식": 1
},
"E1": {
"값": "수량(m당)",
"서식": 1
}
}
"칸": {}
}
],
"활성": "s1",
"표번": "표-50"
"표번": "SC010102"
}
+1 -1
View File
@@ -3,7 +3,7 @@
"종류": "structure",
"판": 1,
"열": "c1",
"도번": "구-50",
"도번": "SD010102",
"도면": {
"format": 6,
"entities": [],
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,9 @@
<!doctype html>
<html lang="ko">
<html lang="ko" data-theme="light">
<head>
<meta charset="UTF-8" />
<title>C 격자 시험 틀</title>
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
<style>
html,
body {
+10 -3
View File
@@ -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).
"""
@@ -19,12 +19,13 @@ OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
SCRIPT = """
const { navRows, selectionOf } = await import(process.argv[1]);
const { navRows, selectionOf, isBlankPick } = await import(process.argv[1]);
const doc = JSON.parse(process.argv[2]);
console.log(JSON.stringify({
master: navRows(doc, "master"),
project: navRows(doc, "project"),
pick: [selectionOf(doc, "s:drawing", "c2"), selectionOf(doc, "d:r1", "c1"), selectionOf(doc, "s:unit", "zz")],
blank: ["s:price", "s:drawing", "s:basis", "s:desc", "d:r1"].map((k) => [isBlankPick(k, { id: "t", 머리: [], 꼴: "글" }), isBlankPick(k, { id: "n", 머리: [], 꼴: "수" })]),
}));
"""
@@ -51,7 +52,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"]
@@ -62,3 +63,9 @@ def test_selection_shape():
{"row": "d:r1", "col": 0, "colId": "c1"},
{"row": "s:unit", "col": -1, "colId": None},
]
def test_text_column_pick_rows_blank():
assert _run()["blank"] == [
[True, False], [True, False], [True, False], [False, False], [False, False],
] # fmt: skip
@@ -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"]
+7
View File
@@ -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;
+64 -15
View File
@@ -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,28 @@ import { createButton, el } from "@ui/ui_template_elements";
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
import {
addColumn,
addGroup,
addRow,
canDeleteRow,
deleteColumn,
deleteRow,
isBlankPick,
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 +51,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 +77,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 +115,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 +138,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 +205,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 +214,49 @@ 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 (isBlankPick(sel.r, 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 +289,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 => {
+42 -2
View File
@@ -19,10 +19,18 @@ 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];
/** 글 열(NO · 측점 · 비고 같은 꼴=글)의 일위대가 · 도면 · 산출근거 칸 — 빈칸 · 잠김. */
export const isBlankPick = (key: string, col: SheetColumn): boolean =>
PICK_ROWS.includes(key) && col.꼴 === "글";
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
@@ -124,6 +132,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);
+29 -14
View File
@@ -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";
@@ -13,11 +14,14 @@ import { columnNames, formulaLabel, headMinWidths, textWidth } from "./ui_templa
import {
cellEditable,
isBoundColumn,
isBlankPick,
isCalcColumn,
KEY_BASIS,
KEY_DRAWING,
KEY_UNIT,
MASTER_ROWS,
type MasterRowKey,
PICK_ROWS,
pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
@@ -34,13 +38,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 +63,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 || isBlankPick(key, col)) 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 +94,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 +106,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,20 +174,24 @@ 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))] });
cols.forEach((col, j) => {
const text =
key === "s:formula"
const blank = isBlankPick(key, col);
const text = blank
? ""
: key === "s:formula"
? 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) && !blank) td.classList.add("is-pick");
if (key === "s:formula" && col.식) {
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
td.textContent = "";
@@ -209,7 +222,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"],