Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-30 10:48:47 +09:00
19 changed files with 306 additions and 61 deletions
@@ -38,7 +38,7 @@ const TEXT = {
empty: "맞는 조합 없음",
none: "조합 없음",
use: "이 열에 쓰기",
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로",
useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로",
useOk: "바꿈",
used: "이 열이 {value} 을 씀",
loadFailed: "조합 목록을 못 받음 — {value}",
@@ -48,9 +48,9 @@ const TEXT = {
count: "로직 {logics} · 입력 {inputs}",
chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}",
auto: "자동값",
noItem: "집계표를 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴",
noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴",
noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름",
unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴",
noLogic: "이 일위대가에 담긴 로직이 없음",
missing: "빠진 로직 {value}",
failed: "입력 목록을 못 받음 — {value}",
@@ -169,7 +169,7 @@ def get_template(kind: str, name: str) -> dict:
@router.put("/templates/{kind}/{name}")
def put_template(kind: str, name: str, body: SaveBody) -> dict:
return _call(store.write, kind, name, body.판, body.문서)
return _call(store.write, kind, name, body.판, body.문서, True) # 화면 [저장](26-7e)
@router.delete("/templates/{kind}/{name}")
@@ -305,7 +305,7 @@ async def save_layer_template(
def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict:
with store.at(folder):
try:
info = store.write(kind, name, version, doc)
info = store.write(kind, name, version, doc, screen=True) # 화면 [저장](26-7e)
except store.StoreError as error:
raise HTTPException(status_code=error.status, detail=error.detail) from error
return {"종류": kind, "이름": name, "판": info["판"], "층": layer}
@@ -150,7 +150,7 @@ def _check_skeleton(kind: str, doc: Any) -> None:
raise StoreError(400, str(e)) from e
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]:
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
@@ -158,7 +158,9 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로)."""
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
`screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로
(정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`)."""
path = _path(kind, name)
_check_skeleton(kind, doc)
origins = _take_origins(doc) if kind == "table" else {}
@@ -170,6 +172,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
have = version_of(path.read_bytes()) if path.is_file() else ""
if have != (version or ""):
raise StoreError(409, {"stale": [name], "판": have})
if screen and have and kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes())))
if have and kind in _KEY:
number = json.loads(path.read_bytes()).get(_KEY[kind])
doc = {**doc, _KEY[kind]: number} if number else doc
@@ -57,12 +57,12 @@ const TEXT = {
none: "고를 것이 없음",
pickPrice: "일위대가 고르기",
combosFailed: "일위대가 목록을 못 받음 — {value}",
saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김",
saveFirst: "새 열은 구조물 조합을 먼저 저장해야 번호가 생김",
overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로",
pickCopy: "{value} 복사해 가져오기",
copied: "복사해 가져옴",
copyFailed: "복사 실패 — {value}",
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물집계표 일위대가 줄에서 고름",
noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름",
noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음",
inputsFailed: "일위대가 입력값을 못 받음 — {value}",
};
@@ -180,6 +180,30 @@ export interface StructureColumn {
/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */
export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수";
/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b) — 비고 열 · 전구간 줄(본문 줄) · 합계 줄.
* 양식 파일은 그대로 — 보일 문서에서만 빼고 [저장] 때 저장본 것을 다시 끼움(`comboMerge`). */
const HIDDEN_COLUMNS = ["memo"];
type ComboDoc = { 열: StructureColumn[]; 줄?: unknown[]; 합계줄?: unknown[] };
export function comboView<T>(doc: T): T {
const d = doc as unknown as ComboDoc;
const 열 = (d.열 ?? []).filter((c) => !HIDDEN_COLUMNS.includes(c.id));
return { ...d, 열, 줄: [], 합계줄: [] } as unknown as T;
}
/** 보인 표 고침 + 저장본의 숨긴 것 — 열 차례는 저장본(표에서 열을 더하거나 지우지 않음 · 26-7d) */
export function comboMerge<T>(stored: unknown, edited: T): T {
const s = stored as ComboDoc;
const e = edited as unknown as ComboDoc;
const byId = new Map(e.열.map((c) => [c.id, c]));
const kept = (s.열 ?? []).flatMap((c) =>
HIDDEN_COLUMNS.includes(c.id) ? [c] : byId.has(c.id) ? [byId.get(c.id)!] : [],
);
const known = new Set(kept.map((c) => c.id));
const 열 = [...kept, ...e.열.filter((c) => !known.has(c.id))];
return { ...e, 열, 줄: s.줄 ?? [], 합계줄: s.합계줄 ?? [] } as unknown as T;
}
/** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */
export const columnHead = (col: StructureColumn): string =>
col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id;
@@ -108,6 +108,20 @@ def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
return out
def keep_paths(doc: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
"""화면 표 [저장](PLAN 26-7e) — 정본은 나무: 있던 항목의 경로(머리) · 층 이름 · 빈 폴더는
저장본 그대로 · 브라우저가 보낸 머리는 안 받음. 머리는 저장 맞춤(`normalize_table`)이 경로에서
다시 만듦."""
out = copy.deepcopy(doc)
was = {c["id"]: path_of(c) for c in _items(stored)}
for col in _items(out):
if col["id"] in was:
_set_path(col, was[col["id"]])
out["층"] = copy.deepcopy(stored.get("층") or [])
out["폴더"] = copy.deepcopy(stored.get("폴더") or [])
return out
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
out: list[list[str]] = []
@@ -233,7 +233,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
showToast(L("M02_TreeDone"), "success");
await opt.onChanged(col);
} catch (error) {
if (error instanceof StaleError) {
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
if (error instanceof StaleError && error.message === "HTTP 409") {
await refresh();
showToast(L("M02_TreeStale"), "error");
} else {
@@ -17,6 +17,8 @@ import {
type StructureDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import {
comboMerge,
comboView,
createStructurePanel,
pillsFor,
STRUCTURE_TABLE,
@@ -103,9 +105,11 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean {
return layer === "project" || isAdmin;
}
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 ·
* `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */
export interface MainExtras {
top?: HTMLElement;
aside?: HTMLElement;
onColumn?: (column: string | null) => void;
}
@@ -125,7 +129,7 @@ export function createMain(
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너
const root = el("div", {
const column = el("div", {
className: "m02-main",
children: [
...(extras.top ? [extras.top] : []),
@@ -135,6 +139,15 @@ export function createMain(
extra,
],
});
// 왼쪽 나무(주) · 오른쪽 편집 칸 — 나무는 「구조물 조합」일 때만(`showAside`)
const root = extras.aside
? el("div", { className: "m02-shell", children: [extras.aside, column] })
: column;
const isCombo = (at: Selection | null): boolean =>
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
const showAside = (): void => {
if (extras.aside) extras.aside.hidden = !isCombo(sel);
};
let sel: Selection | null = null;
let version = "";
@@ -360,9 +373,11 @@ export function createMain(
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
editor = create(host, doc, {
// 「구조물 조합」 = 나무를 따라오는 참조표(26-7b · d) — 비고 · 전구간 · 합계 숨김 · 열 · 머리는 나무만
editor = create(host, struct ? comboView(doc) : doc, {
mode: at.layer === "project" ? "project" : "master",
readOnly,
lockHead: !!struct,
onChange: () => (dirty = !readOnly),
...(struct
? {
@@ -382,7 +397,11 @@ export function createMain(
}
: {}),
});
if (struct) sheet = editor as SheetEditorHandle;
if (!struct) return;
const shown = editor as SheetEditorHandle;
sheet = shown;
// [저장]은 저장본의 숨긴 것을 다시 끼운 문서(서버가 머리를 경로에서 다시 만듦 · 26-7e)
editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy };
return;
}
if (at.kind === "area" || at.kind === "material") {
@@ -421,11 +440,12 @@ export function createMain(
sel = next;
onOpened?.();
bar();
showAside();
if (!next) {
title.textContent = L("M02_PickTemplate");
return empty("");
}
title.textContent = next.label ?? next.name;
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
try {
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
if (mine !== seq) return;
@@ -501,6 +521,7 @@ export function createMain(
sel = null;
onOpened?.(); // 좌측 양식 · 열 줄 고름 지움
bar();
showAside();
title.textContent = label;
host.replaceChildren(node);
return (d) => (dirty = d);
@@ -544,6 +565,7 @@ export function createMain(
if (sel && (await confirmLeave())) void open(sel);
});
bar();
showAside();
empty("");
return { root, open, current: () => sel, confirmLeave, showView, openTable, settle };
}
@@ -1,6 +1,7 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품)
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) ·
* 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c)
*
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
@@ -8,6 +9,7 @@
import "@ui/ui_template_workflow_layout.css";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
@@ -19,7 +21,8 @@ import {
titleOf,
} from "./M02_MasterTemplete_Layer";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
import "./M02_MasterTemplete_UI_Style.css";
@@ -35,15 +38,28 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
// 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽
const tree = createTreePanel({
layer,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
onChanged: (colId) => main.openTable(layer, colId, true),
});
const aside = el("aside", {
className: "m02-shell__tree",
children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root],
});
const main = createMain(
isAdmin,
() => void side.refresh(),
() => void Promise.all([side.refresh(), tree.refresh()]),
() => side.paint(),
{
top: readOnlyBar?.root,
aside,
onColumn: (column) => {
readOnlyBar?.setColumn(column);
side.selectItem(column);
tree.select(column);
},
},
);
@@ -61,11 +77,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: {
onPick: (item) => void main.openTable(layer, item.열),
settle: main.settle,
onChanged: (colId) => main.openTable(layer, colId, true),
},
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
@@ -85,4 +96,5 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
root.innerHTML = "";
root.append(layout);
void main.open(home);
void tree.refresh();
}
@@ -1,10 +1,12 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 「구조물 집계 양식」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
* 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 ·
* 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
import {
@@ -38,7 +40,6 @@ import {
} from "./M02_MasterTemplete_Structure";
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
import { createTreePanel, type TreePanelOptions } from "./M02_MasterTemplete_TreePanel";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
const KINDS: { kind: Kind; label: () => string }[] = [
@@ -72,8 +73,6 @@ export interface SideOptions {
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 항목 나무(26-3a) — 항목 누름 · 고치기 전 저장 처리 · 고친 뒤 표 다시 읽기 */
tree: Pick<TreePanelOptions, "onPick" | "settle" | "onChanged">;
}
export interface SideHandle {
@@ -81,8 +80,6 @@ export interface SideHandle {
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 표에서 고른 열을 항목 나무에 표시 */
selectItem: (colId: string | null) => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
@@ -155,15 +152,10 @@ export function buildSide(opt: SideOptions): SideHandle {
);
summary.classList.remove("is-collapsed");
summary.classList.add("m02-side__summary");
// 항목 나무 — 구조물 집계 양식 아래 · 고르기 모달과 같은 폴더 나무(세 층 공용)
const tree = createTreePanel({ layer, canEdit: canEdit(layer, opt.isAdmin), ...opt.tree });
const treeSection = section(L("M02_TreeTitle"), tree.root);
treeSection.classList.remove("is-collapsed");
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
treeSection,
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만
...(layer === "system"
? [
@@ -201,7 +193,8 @@ export function buildSide(opt: SideOptions): SideHandle {
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
text: info.이름,
// 구조물집계표 = 화면 이름 「구조물 조합」(26-7a)
text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => void select(info));
@@ -289,7 +282,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (mine !== seq) return;
paintList();
paintButtons();
await Promise.all([refreshStructure(), matset.refresh(), tree.refresh()]);
await Promise.all([refreshStructure(), matset.refresh()]);
}
/* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */
@@ -370,5 +363,5 @@ export function buildSide(opt: SideOptions): SideHandle {
}
void refresh();
return { root, refresh, paint, selectItem: tree.select };
return { root, refresh, paint };
}
@@ -215,3 +215,53 @@
color: var(--color-text-muted);
font-size: 11px;
}
/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */
.m02-shell {
display: flex;
align-items: stretch;
min-width: 0;
}
.m02-shell > .m02-main {
flex: 1 1 0;
}
.m02-shell__tree {
display: flex;
flex: 0 0 16rem;
flex-direction: column;
box-sizing: border-box;
max-height: calc(100vh - var(--spacing-64));
padding: var(--spacing-16) var(--spacing-12);
border-right: 1px solid var(--color-border);
min-width: 0;
overflow: auto;
}
.m02-shell__tree[hidden] {
display: none;
}
.m02-shell__title {
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-shell__tree .m02-tree__body {
max-height: none;
}
@media (max-width: 720px) {
.m02-shell {
flex-direction: column;
}
.m02-shell__tree {
flex-basis: auto;
max-height: 40vh;
border-right: 0;
border-bottom: 1px solid var(--color-border);
}
}
+107
View File
@@ -0,0 +1,107 @@
"""관리 화면 「구조물 조합」(PLAN 26-7a~d) — 이름 · 숨김(비고 · 전구간 · 합계) · 참조표 잠금 ·
나무 자리.
① `comboView` · `comboMerge`(Node) — 보일 문서에서 비고 열 · 줄 · 합계 줄을 빼고, [저장] 때
저장본 것을 다시 끼움(열 차례 = 저장본 · 보인 고침은 받음).
② 표 부품 `lockHead` — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(단위 · 조회 후 선택은
그대로).
③ 좌측 패널에 나무 없음 · 페이지가 나무를 편집 칸 왼쪽(`aside`)에 · 「구조물 조합」일 때만 보임.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
SHEET = ROOT / "ui_template" / "sheet"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_숨긴_것은_저장_때_저장본에서_다시_끼움():
src = _text(M02 / "M02_MasterTemplete_Structure.ts")
part = re.search(
r"^const HIDDEN_COLUMNS[\s\S]*?^export function comboMerge[\s\S]*?^}", src, re.M
)
assert part, "comboView · comboMerge 를 못 찾음"
script = (
"interface StructureColumn { id: string; 머리: (string | null)[]; 꼴?: string | null;"
" 단위?: string }\n"
+ part.group(0).replace("export ", "")
+ """
const stored = {
열: [{ id: "no", 머리: ["NO"] }, { id: "a", 머리: ["가"], 꼴: "수" },
{ id: "b", 머리: ["나"], 꼴: "수" }, { id: "memo", 머리: ["비고"] }],
줄: [{ id: "all", 값: { memo: "x" } }], 합계줄: [{ id: "sum" }], 변수: { k: 1 },
};
const view = comboView(stored);
const edited = structuredClone(view);
edited.열[2].단위 = "m";
const saved = comboMerge(stored, edited);
console.log(JSON.stringify([
view.열.map((c) => c.id), view.줄, view.합계줄, stored.열.length,
saved.열.map((c) => c.id), saved.열[2].단위, saved.줄, saved.합계줄, saved.변수,
]));
"""
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["no", "a", "b"],
[],
[],
4, # 저장본은 안 바뀜
["no", "a", "b", "memo"],
"m",
[{"id": "all", "값": {"memo": "x"}}],
[{"id": "sum"}],
{"k": 1},
]
def test_참조표는_열_머리를_못_고침():
sheet = _text(SHEET / "ui_template_sheet.ts")
assert 'const editCols = opts.mode === "master" && !ro && !opts.lockHead;' in sheet
assert "if (editCols) toolbar.append(colDelete);" in sheet
assert "if (editCols) syncLevels(!!col);" in sheet
assert "if (th && editCols) editHead(th);" in sheet
assert "else if (layer && editCols) editLayer(layer);" in sheet
assert 'ro || opts.lockHead ? { hidden: "" }' in sheet
render = _text(SHEET / "ui_template_sheet_render.ts")
assert 'const heads = mode === "master" && !state.headLocked;' in render
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "struct ? comboView(doc) : doc" in main
assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main
def test_나무는_편집_칸_왼쪽_좌측_패널엔_없음():
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert "createTreePanel" not in side and "M02_TreeTitle" not in side
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createTreePanel({" in page and "aside," in page and "tree.select(column)" in page
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main
assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목
assert main.count("showAside();") == 3 # 열 때 · 양식 밖 화면 · 처음
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
assert 'M02_KindTable: ["구조물 조합"' in locale
assert ".m02-shell__tree[hidden]" in _text(M02 / "M02_MasterTemplete_UI_Style.css")
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert (
'error instanceof StaleError && error.message === "HTTP 409"' in tree
) # 같은 이름 = 까닭 글
+2 -1
View File
@@ -147,5 +147,6 @@ def test_읽기만_표는_단추_고치기_모두_막음():
assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7
# 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열
assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet
assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts"))
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert re.search(r"readOnly,\s*lockHead: !!struct,\s*onChange", main)
assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css")
+1 -1
View File
@@ -153,7 +153,7 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우
# 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘)
summary = side.split("const summary = section(")[1].split(");")[0]
assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary
assert 'M02_KindTable: ["구조물 집계 양식"' in _text(
assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a)
ROOT / "ui_template" / "ui_template_locale_m2.ts"
)
+15 -10
View File
@@ -220,27 +220,32 @@ def test_판_409_권한_403(world):
def test_표_저장과_나무가_한_줄기(world):
"""집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임."""
"""정본 = 나무(PLAN 26-7e) — 표 [저장]이 보낸 머리 · 층 이름 · 빈 폴더는 안 받음(저장본 경로
그대로) · 나무 고침 뒤 표 머리가 경로에서 다시 만들어짐."""
admin = _as(world, "SYSTEM_ADMIN")
t = Tree(admin, "system")
rv = next(r for r in t.now["items"] if r["열"] == "rv")
path = rv["경로"]
url = f"/api/m02/layers/system/templates/{TABLE}"
got = admin.get(url).json()
col = next(c for c in got["문서"]["열"] if c["id"] == "rv")
col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀
col["머리"] = ["돌기슭막이", None, "찰쌓기", None]
col["단위"] = "㎡" # 머리 밖 칸은 받음
got["문서"]["층"] = ["가", "나", "다", "라"]
got["문서"]["폴더"] = [["표에서 만든 폴더"]]
assert admin.put(url, json=got).status_code == 200
table = admin.get(url).json()["문서"]
col = next(c for c in table["열"] if c["id"] == "rv")
assert col["경로"] == path and col["단위"] == "㎡"
assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == []
t = Tree(admin, "system")
assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"]
assert t.item(path)["key"] == rv["key"]
t.do(op="rename", 키=rv["key"], 이름="메쌓기")
table = admin.get(url).json()["문서"]
col = next(c for c in table["열"] if c["id"] == "rv")
assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [
"돌기슭막이",
"메쌓기",
None,
None,
]
# 표에서 열을 지우면 나무에서도 빠지고 파일도 같이
assert col["경로"] == [*path[:-1], "메쌓기"]
assert col["머리"] == [*path[:-1], "메쌓기", *[None] * (4 - len(path))]
# (화면엔 단추 없음 · API 호환) 표 문서에서 열을 빼고 저장하면 나무에서도 빠지고 파일도 같이
table["열"] = [c for c in table["열"] if c["id"] != "rv"]
assert (
admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200
+14 -5
View File
@@ -10,6 +10,8 @@
* 치지 않고 조회 후 선택.
* `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 ·
* 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기).
* `lockHead` = 열 · 머리는 밖(나무)이 정함 — 열 더하기 · 지우기 단추 없음 · 머리 · 층 이름
* 못 고침(단위 · 일위대가 · 조회 후 선택은 그대로 · PLAN 26-7d).
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
@@ -49,6 +51,8 @@ export interface SheetOptions {
mode: SheetMode;
/** 볼 수만 있음(단추 · 고치기 없음) */
readOnly?: boolean;
/** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */
lockHead?: boolean;
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
@@ -84,10 +88,15 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
areaLabel: opts.areaLabel,
materialLabel: opts.materialLabel,
pick: !!opts.onPick,
headLocked: !!opts.lockHead,
};
const ro = !!opts.readOnly;
const editCols = opts.mode === "master" && !ro && !opts.lockHead; // 열 · 머리 고치기
const scroll = el("div", { className: "ui-sheet__scroll" });
const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} });
const toolbar = el("div", {
className: "ui-sheet__toolbar",
attrs: ro || opts.lockHead ? { hidden: "" } : {},
});
const root = el("div", {
className: `ui-sheet ui-sheet--${opts.mode}`,
attrs: { tabindex: "0" },
@@ -133,7 +142,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" && !ro) toolbar.append(colDelete);
if (editCols) toolbar.append(colDelete);
// 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 ·
// 층 이름 · 층 수가 바뀌면 다시 만듦
@@ -159,7 +168,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
const syncToolbar = (): void => {
const col = state.sel ? colOf(state.sel.c) : undefined;
if (opts.mode === "master" && !ro) syncLevels(!!col);
if (editCols) syncLevels(!!col);
colDelete.disabled = !col || state.doc.열.length <= 1;
};
@@ -295,8 +304,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
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);
if (th && editCols) editHead(th);
else if (layer && editCols) editLayer(layer);
else if (target.closest("td[data-r]")) edit();
};
const onKey = (event: KeyboardEvent): void => {
@@ -45,6 +45,8 @@ export interface RenderState {
materialLabel?: (colId: string) => string;
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
pick?: boolean;
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
headLocked?: boolean;
}
/** 도면 · 면적산출 · 재료산출 줄 칸 글. */
@@ -194,7 +196,8 @@ export function renderSheet(state: RenderState): HTMLElement {
const thead = (): HTMLElement => {
const rows = head.map((cells, level) => {
const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : "");
const heads = mode === "master" && !state.headLocked;
const layer = gutter("th", doc.층?.[level] ?? "", heads ? "is-editable" : "");
layer.dataset.layer = String(level);
const tr = el("tr", { children: [layer] });
for (const cell of cells) {
@@ -208,7 +211,7 @@ export function renderSheet(state: RenderState): HTMLElement {
"data-col": cols[cell.col].id,
},
});
if (mode === "master") th.classList.add("is-editable");
if (heads) th.classList.add("is-editable");
freeze(th, cell.col, cell.colspan);
tr.append(th);
}
+3 -3
View File
@@ -6,7 +6,7 @@
export const ui_locales_m2 = {
B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"],
M02_Title: ["마스터 템플릿", "Master Templates"],
M02_KindTable: ["구조물 집계 양식", "Structure summary templates"],
M02_KindTable: ["구조물 조합", "Structure combos"],
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_KindStructure: ["구조물 도면", "Structure drawings"],
M02_KindArea: ["면적산출", "Area takeoff"],
@@ -66,11 +66,11 @@ export const ui_locales_m2 = {
"Company templates are read-only — copy the selected column to your own to edit",
],
M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"],
M02_CopyPickFirst: ["복사할 열을 집계표에서 먼저 고름", "Pick a column in the table first"],
M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"],
M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"],
M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"],
M02_NeedCompany: [
"회사부터 등록해야 구조물 집계표를 만들 수 있음",
"회사부터 등록해야 구조물 조합을 만들 수 있음",
"Register a company first to manage structure templates",
],
M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"],