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