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

This commit is contained in:
2026-09-30 20:28:11 +09:00
17 changed files with 367 additions and 90 deletions
@@ -447,6 +447,8 @@ export interface TreeEdit {
새경로?: string[];
키?: string;
단위?: string;
/** 항목 추가 때 고른 일위대가 조합 키(PLAN 31-2) */
일위대가?: string;
}
/** `owner` = 남의 층(없으면 자기 층) — 지금 층과 따로 받음(나무 부품이 여러 뿌리를 그림) */
@@ -53,6 +53,7 @@ class TreeBody(BaseModel):
새경로: list[str] | None = None
키: str | None = None
단위: str | None = None
일위대가: str | None = None # 항목 추가 때 고른 조합 키(PLAN 31-2)
판: str
# 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번
대상: list[dict[str, Any]] | None = None
@@ -6,39 +6,55 @@
display: contents;
}
/* 카드 셋 나란히(같은 폭) · 카드 하나가 300px 안 되면 자동으로 2열 → 1열(칸 폭에 따라 · 창이 작아지면 바로 바뀜) */
/* 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) = 2행 2열 · 카드 높이 고정(PLAN 31-2) ·
칸이 좁으면(720px 아래) 1열 */
.m02-struct {
/* CAD 미리보기 · 면적산출 · 재료산출 내용 최소 높이 — 이보다 작아질 때만 화면이 스크롤 */
--m02-struct-body-min: 16rem;
/* 카드 본문은 카드 높이를 채움 — 넘치면 카드 안에서 스크롤 */
--m02-struct-body-min: 0;
--m02-struct-card-h: 360px;
display: grid;
flex: 1 1 0; /* 메인 칸(창 높이)에서 표 아래 남은 높이를 다 차지 */
grid-auto-rows: minmax(auto, 1fr); /* 카드 셋 같은 높이 */
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
flex: none;
grid-auto-rows: var(--m02-struct-card-h);
grid-template-columns: repeat(2, minmax(0, 1fr));
padding-top: var(--spacing-12);
border-top: 1px solid var(--color-border);
min-width: 0;
}
@media (max-width: 720px) {
.m02-struct {
grid-template-columns: minmax(0, 1fr);
}
}
.m02-struct > * {
min-width: 0;
}
/* 집계표 칸을 골라 카드가 보이면 메인 칸을 창 높이로 고정 — 표 아래 남은 높이만큼만 카드(스크롤 없음) */
/* 카드가 보이면 상세 칸은 창 높이까지 — 표 + 카드가 넘치면 상세 칸 전체가 스크롤 */
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) {
box-sizing: border-box;
height: calc(100vh - var(--spacing-64));
max-height: calc(100vh - var(--spacing-64));
overflow-y: auto;
}
/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) — 창이 작으면 카드가 최소 높이에서 멈추고 화면이 스크롤 */
/* 표는 줄어들지 않음(줄면 표가 카드 위로 넘침) */
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) > .m02-main__host {
flex-shrink: 0;
}
/* 카드 = 제목 + 머리 + 본문 — 본문이 남는 높이를 채움 */
/* 카드 = 제목 + 머리 + 본문 — 본문이 남는 높이를 채움 · 넘치면 카드 안 스크롤 */
.m02-struct__card {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.m02-struct__card > :last-child {
flex: 1 1 0;
min-height: 0;
overflow: auto;
}
/* ui-sidebar-panel 은 display:flex 라 hidden 속성을 덮어씀 — 안 보일 땐 확실히 안 보이게 */
@@ -167,6 +167,34 @@ function choose(title: string, items: Choice[], clearable: boolean): Promise<str
});
}
/** 일위대가 고르기 창 — 이 층이 쓸 수 있는 세 층 조합 · 찾기 = 띄어쓰기 AND · 「비움」 = 키 빈 글 · 닫으면 null
* (표 칸 고르기 · 항목 추가 창이 함께 씀 · PLAN 31-2) */
export async function pickCombo(title: string): Promise<{ key: string; label: string } | null> {
let list: Awaited<ReturnType<typeof loadCombos>>;
try {
list = await loadCombos();
} catch (error) {
showToast(fill(TEXT.combosFailed, why(error)), "error");
return null;
}
const items = list.map((c) => ({
value: c.키,
key: c.키,
// 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌
label: [
c.이름,
c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`,
c.구분,
`· ${LAYER_WORD[c.layer]}`,
]
.filter(Boolean)
.join(" "),
}));
const key = await choose(title, items, true);
if (key === null) return null;
return { key, label: items.find((it) => it.value === key)?.label ?? "" };
}
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
export interface StructureColumn {
id: string;
@@ -341,28 +369,9 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
};
const pickPrice = async (colId: string, columns: StructureColumn[]): Promise<string | null> => {
let list: Awaited<ReturnType<typeof loadCombos>>;
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.구분,
`· ${LAYER_WORD[c.layer]}`,
]
.filter(Boolean)
.join(" "),
}));
return choose(col ? `${TEXT.pickPrice} — ${columnHead(col)}` : TEXT.pickPrice, items, true);
const got = await pickCombo(col ? `${TEXT.pickPrice} — ${columnHead(col)}` : TEXT.pickPrice);
return got ? got.key : null;
};
/** 다른 열의 도면 · 면적산출 · 재료산출을 골라 내용만 복사 — 덮어쓰기 확인 · 보고 있던 열이면 다시 읽음 */
@@ -246,7 +246,8 @@ def apply(
made = [*where, _name(body.get("이름"))]
_taken_item(out, made)
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
col.update({"일위대가": None, "자재조합": None})
# 항목 추가 창에서 고른 일위대가(PLAN 31-2) — 없으면 비움 · 단위는 안 받아도 됨
col.update({"일위대가": body.get("일위대가") or None, "자재조합": None})
_set_path(col, made)
_place(out, col)
note.update({"경로": made, "열": col["id"]})
@@ -35,7 +35,8 @@ import {
type TreeRef,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
import {
clipOf,
keepClip,
@@ -82,6 +83,8 @@ export interface TreePanelOptions {
};
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
clipKind?: string;
/** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */
pickCombo?: () => Promise<{ key: string; label: string } | null>;
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
copyTo?: { layer: Layer; owner: string | null; label: string }[];
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
@@ -286,49 +289,18 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
},
{ capture: true },
);
// 끌어다 옮기기(30-2) — 쓸 수 있는 층만 · 고른 것 안의 줄을 끌면 고른 것 전부
if (opt.canEdit) {
button.draggable = true;
button.addEventListener("dragstart", (event) => {
const own = targets.get(one.id);
dragging = picked.size > 1 && picked.has(one.id) ? pickedTargets() : own ? [own] : null;
event.dataTransfer?.setData("text/plain", one.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
button.addEventListener("dragend", () => {
dragging = null;
dropMark(null);
});
}
if (opt.canEdit) drag.arm(button, one.id); // 끌어다 옮기기(30-2) — 쓸 수 있는 층만
return button;
};
/* --- 끌어다 놓기: 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 제 안으로는 안 됨 --- */
let dragging: Target[] | null = null;
const dropDest = (event: DragEvent): string[] | null => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
const target = row ? targets.get(row.dataset.node ?? "") : undefined;
const dest = !row ? [] : target?.kind === "folder" ? target.path : null;
const blocked = dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path));
return dest && !blocked ? dest : null;
};
const dropMark = (row: HTMLElement | null): void => {
body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop"));
row?.classList.add("is-drop");
};
body.addEventListener("dragover", (event) => {
if (!dragging || !dropDest(event)) return;
event.preventDefault();
dropMark((event.target as HTMLElement).closest<HTMLElement>("[data-node]") ?? body);
});
body.addEventListener("drop", (event) => {
const dest = dragging && dropDest(event);
const list = dragging;
dragging = null;
dropMark(null);
if (!dest || !list) return;
event.preventDefault();
void dropMove(list, dest);
const drag = attachTreeDrag({
body,
targetOf: (id) => targets.get(id),
listFor: (id) => {
const own = targets.get(id);
return picked.size > 1 && picked.has(id) ? pickedTargets() : own ? [own] : null;
},
move: (list, dest) => void dropMove(list, dest),
});
/** 놓은 자리로 옮김 — 이미 그 자리인 것은 뺌 · 하나면 한 개 옮기기 · 여럿이면 한 번에 */
@@ -495,16 +467,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
if (opt.item?.onAdd) return opt.item.onAdd(parent);
const got = await askText(L("M02_TreeAddItem"), [
{ key: "name", label: L("M02_TreeItemName") },
{ key: "unit", label: L("M02_TreeUnit") },
]);
// 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2)
const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : [];
const got = await askNewItem({
title: L("M02_TreeAddItem"),
kinds,
at: parent,
pickCombo: opt.pickCombo,
});
if (got) {
await edit({
op: "add-item",
경로: parent,
경로: got.path,
이름: got.name,
...(got.unit ? { 단위: got.unit } : {}),
...(got.combo ? { 일위대가: got.combo } : {}),
});
}
}
@@ -68,6 +68,107 @@ export function askText(
});
}
/** 항목 추가 창이 돌려줌 — `path` = 넣을 폴더(종류 · 고른 폴더) · `combo` = 고른 일위대가 키(없으면 null) */
export interface NewItem {
path: string[];
name: string;
combo: string | null;
}
/** 항목 추가(PLAN 31-2) — 구조물 종류(맨 위 폴더 · 있는 것 고르거나 새 이름 · 나무에서 폴더를 골라 두었으면
* 그 안이라 안 물음) + 이름 + 일위대가(`pickCombo` 고르기 창 · 비워도 됨 — 표에서 나중에) · 단위는 안 받음 */
export function askNewItem(opt: {
title: string;
/** 있는 종류(맨 위 폴더 이름) — 칸에 글을 치면 목록에서 고름 */
kinds: string[];
/** 나무에서 골라 둔 폴더 — 있으면 종류 칸 없음 */
at: string[];
pickCombo?: () => Promise<{ key: string; label: string } | null>;
}): Promise<NewItem | null> {
return new Promise((resolve) => {
let done = false;
const finish = (value: NewItem | null): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: opt.title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
const kind = opt.at.length ? null : createInputField({ label: L("M02_NewItemKind") });
if (kind) {
const list = el("datalist", {
attrs: { id: `m02-kinds-${Date.now()}` },
children: opt.kinds.map((k) => el("option", { attrs: { value: k } })),
});
kind.input.setAttribute("list", list.id);
kind.root.append(list);
}
const where = opt.at.length
? el("p", {
className: "m02-treedlg__at",
text: L("M02_NewItemAt").replace("{value}", opt.at.join(" / ")),
})
: null;
const name = createInputField({ label: L("M02_TreeItemName") });
let combo: { key: string; label: string } | null = null;
const shown = el("span", {
className: "m02-treedlg__combo",
text: L("M02_NewItemNoCombo"),
});
const pick = createButton({
label: L("M02_NewItemPickCombo"),
variant: "ghost",
size: "sm",
});
pick.addEventListener("click", async () => {
const got = await opt.pickCombo?.();
if (got === null || got === undefined) return; // 닫음 = 그대로
combo = got.key ? got : null; // 「비움」 = 없앰
shown.textContent = combo ? `${combo.key} ${combo.label}` : L("M02_NewItemNoCombo");
});
const comboRow = opt.pickCombo
? el("div", {
className: "m02-treedlg__row",
children: [el("span", { text: L("M02_NewItemCombo") }), shown, pick],
})
: null;
const ok = createButton({ label: L("M02_TreeOk"), variant: "filled" });
const need = (field: typeof name, label: string): boolean => {
const empty = !field.input.value.trim();
field.setError(empty ? L("M02_FieldNeeded").replace("{label}", label) : undefined);
return empty;
};
const submit = (): void => {
// 빈 칸은 그 칸에 빨간 테두리 + 한 줄(칸마다)
const badKind = kind ? need(kind, L("M02_NewItemKind")) : false;
const badName = need(name, L("M02_TreeItemName"));
if (badKind || badName) return;
const path = kind ? [kind.input.value.trim()] : opt.at;
finish({ path, name: name.input.value.trim(), combo: combo?.key ?? null });
close();
};
ok.addEventListener("click", submit);
for (const field of [kind, name]) {
if (!field) continue;
field.input.addEventListener("input", () => field.setError(undefined));
field.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) submit();
});
}
body.append(
...[where, kind?.root, name.root, comboRow].filter((n): n is HTMLDivElement => !!n),
ok,
);
(kind ?? name).input.focus();
},
});
});
}
/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */
export function askFolder(
title: string,
@@ -0,0 +1,74 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Drag.ts
* 공용 나무 끌어다 옮기기(PLAN 30-2 · 31-5) — 같은 층 안 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 ·
* 제 안으로는 안 됨 · 고른 것 안의 줄을 끌면 고른 것 전부. 옮기는 일은 부르는 쪽(`move`).
* 놓을 자리 강조는 한 곳만 · 놓음 · 끝 · 칸 밖으로 나감에서 모두 지움(나무 칸 자신 포함).
* ========================================================================== */
import type { Target } from "./M02_MasterTemplete_TreePanel_Pick";
import { isUnder } from "./M02_MasterTemplete_TreePanel_Model";
export interface TreeDragOptions {
/** 나무 칸(줄들의 부모 · 빈 자리 = 맨 위) */
body: HTMLElement;
targetOf: (id: string) => Target | undefined;
/** 이 줄을 끌 때 옮길 것 — 고른 것 안이면 고른 것 전부 */
listFor: (id: string) => Target[] | null;
move: (list: Target[], dest: string[]) => void;
}
/** 칸에 놓기를 달고 줄마다 끌기를 다는 손잡이(`arm`)를 돌려줌 */
export function attachTreeDrag(opt: TreeDragOptions): {
arm: (row: HTMLElement, id: string) => void;
} {
const { body } = opt;
let dragging: Target[] | null = null;
const dropDest = (event: DragEvent): string[] | null => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
const target = row ? opt.targetOf(row.dataset.node ?? "") : undefined;
const dest = !row ? [] : target?.kind === "folder" ? target.path : null;
const blocked = dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path));
return dest && !blocked ? dest : null;
};
/** 놓을 자리 강조 — 한 곳만 · null = 모두 지움(나무 칸 자신 = 맨 위 자리 포함 · PLAN 31-5 흠) */
const dropMark = (row: HTMLElement | null): void => {
body.classList.remove("is-drop");
body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop"));
row?.classList.add("is-drop");
};
// 나무 칸 밖으로 나가면 강조를 지움 — 칸 안 자식 위로 옮겨 갈 때는 그대로(깜빡임 막음)
body.addEventListener("dragleave", (event) => {
const to = event.relatedTarget as Node | null;
if (!to || !body.contains(to)) dropMark(null);
});
body.addEventListener("dragover", (event) => {
if (!dragging || !dropDest(event)) return;
event.preventDefault();
dropMark((event.target as HTMLElement).closest<HTMLElement>("[data-node]") ?? body);
});
body.addEventListener("drop", (event) => {
const dest = dragging && dropDest(event);
const list = dragging;
dragging = null;
dropMark(null);
if (!dest || !list) return;
event.preventDefault();
opt.move(list, dest);
});
return {
arm: (row, id) => {
row.draggable = true;
row.addEventListener("dragstart", (event) => {
dragging = opt.listFor(id);
event.dataTransfer?.setData("text/plain", id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
row.addEventListener("dragend", () => {
dragging = null;
dropMark(null);
});
},
};
}
@@ -163,11 +163,24 @@ export function createMain(
const root = column;
const isCombo = (at: Selection | null): boolean =>
!!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project";
// 찾기 칸 · 「새 항목」은 「구조물 조합」일 때만
// 찾기 칸 · 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
const showAside = (): void => {
if (extras.search) extras.search.hidden = !isCombo(sel);
if (extras.newItem) extras.newItem.hidden = !isCombo(sel);
const combo = isCombo(sel);
if (extras.search) extras.search.hidden = !combo;
if (extras.newItem) extras.newItem.hidden = !combo;
title.classList.toggle("m02-main__title--fold", combo);
title.title = combo ? L("M02_TableFold") : "";
if (!combo) {
host.hidden = false; // 다른 상세(도면 · 면적 · 재료)는 늘 펼침
title.classList.remove("is-folded");
}
};
// 「구조물 조합」 표 접기(PLAN 31-2) — 제목을 누르면 표만 접힘 · 펴짐(카드 · 찾기는 그대로)
title.addEventListener("click", () => {
if (!isCombo(sel)) return;
host.hidden = !host.hidden;
title.classList.toggle("is-folded", host.hidden);
});
let sel: Selection | null = null;
let version = "";
@@ -30,6 +30,7 @@ import {
import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor";
import {
headRenameEdit,
pickCombo,
STRUCTURE_TABLE,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
@@ -79,6 +80,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
layer,
owner,
copyTo,
pickCombo: () => pickCombo(L("M02_TreePickCombo")), // 항목 추가 창 일위대가(31-2)
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
@@ -188,6 +188,8 @@ export function buildSide(opt: SideOptions): SideHandle {
],
});
attachCollapsible(listHost);
// 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침)
labor?.body.closest("section")?.classList.remove("is-collapsed");
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
@@ -242,3 +242,25 @@
.m02-main__new[hidden] {
display: none;
}
/* 「구조물 조합」 제목 = 표 접기 · 펴기(PLAN 31-2) — 캐럿은 공용 접기 컨테이너와 같은 모양 */
.m02-main__title--fold {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
cursor: pointer;
user-select: none;
}
.m02-main__title--fold::after {
content: "▾";
color: var(--color-text-muted);
}
.m02-main__title--fold.is-folded::after {
content: "▸";
}
.m02-main__host[hidden] {
display: none;
}
+1 -1
View File
@@ -115,7 +115,7 @@ def test_나무는_좌측_패널_찾기_칸은_상세_위쪽():
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 # 열 때 · 양식 밖 화면 · 처음
assert "if (extras.search) extras.search.hidden = !isCombo(sel);" in main
assert "if (extras.search) extras.search.hidden = !combo;" in main
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
assert 'M02_KindTable: ["구조물 조합"' in locale
assert ".m02-shell" not in _text(
+16 -3
View File
@@ -231,8 +231,9 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
labor = _text(M02 / "M02_MasterTemplete_Labor.ts")
assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit) {\n button.draggable = true;" in panel
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in panel
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in drag
def test_구조물_조합_제목_줄_새_항목():
@@ -244,7 +245,7 @@ def test_구조물_조합_제목_줄_새_항목():
"children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])]"
in main
)
assert "if (extras.newItem) extras.newItem.hidden = !isCombo(sel);" in main
assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert (
"...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => tree.newItem()) } : {})"
@@ -255,3 +256,15 @@ def test_구조물_조합_제목_줄_새_항목():
assert ".m02-main__new {\n margin-left: auto;" in _text(
M02 / "M02_MasterTemplete_UI_Style.css"
)
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
"""31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side
assert 'summary.classList.remove("is-collapsed");' in side
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
mark = drag.split("const dropMark = ")[1].split("};")[0]
assert 'body.classList.remove("is-drop");' in mark
assert "if (!to || !body.contains(to)) dropMark(null);" in drag
+34
View File
@@ -107,3 +107,37 @@ def test_여러_개_옮기기_지우기_한_번에(world):
note = t.do(op="delete", 대상=[{"경로": ["나"], "키": one}, {"경로": ["나", "가"]}])
assert len(note["알림"]["지운항목"]) == 2
assert t.paths() == [["나", "둘"]]
def test_항목_추가_일위대가_단위_없이(world):
"""PLAN 31-2 — 항목 추가 = 종류(폴더) + 이름 + 일위대가 · 단위 안 받아도 됨."""
t = Tree(_as(world), "personal")
t.do(op="add-item", 경로=["옹벽"], 이름="석축", 일위대가="UA000001")
made = t.item(["옹벽", "석축"])
assert made["일위대가"] == "UA000001" and made["단위"] is None
t.do(op="add-item", 경로=["옹벽"], 이름="빈") # 일위대가 비워도 됨
assert t.item(["옹벽", "빈"])["일위대가"] is None
def test_항목_추가_창_카드_격자_표_접기():
"""PLAN 31-2 화면 — 창 = 종류(있는 것 · 새 이름 · 고른 폴더면 안 물음) + 이름 + 일위대가 ·
단위 칸 없음 · 카드 2행 2열 · 높이 고정 · 넘치면 카드 · 상세 칸 스크롤 · 제목 눌러 표 접힘."""
from pathlib import Path
m02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
dlg = (m02 / "M02_MasterTemplete_TreePanel_Dialogs.ts").read_text("utf-8")
assert (
"export function askNewItem(" in dlg and 'kind.input.setAttribute("list", list.id);' in dlg
)
assert "const kind = opt.at.length ? null :" in dlg and "M02_TreeUnit" not in dlg
panel = (m02 / "M02_MasterTemplete_TreePanel.ts").read_text("utf-8")
assert "...(got.combo ? { 일위대가: got.combo } : {})," in panel
assert "askNewItem({" in panel and "M02_TreeUnit" not in panel
css = (m02 / "M02_MasterTemplete_Structure.css").read_text("utf-8")
assert "grid-template-columns: repeat(2, minmax(0, 1fr));" in css
assert (
"grid-auto-rows: var(--m02-struct-card-h);" in css and "--m02-struct-card-h: 360px;" in css
)
assert "max-height: calc(100vh - var(--spacing-64));\n overflow-y: auto;" in css
main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main
@@ -34,12 +34,13 @@ def test_항목_더하기_빈_칸은_칸_안내로_알림():
assert "showToast" not in dialogs # 창 뒤에 가려 안 보이던 토스트 안 씀
locale = _text("ui_template/ui_template_locale_m2.ts")
assert re.search(r'M02_FieldNeeded: \["\{label\}', locale)
# 단위도 비우면 안내 — 옵션(optional)이 아님
# 항목 추가(PLAN 31-2) = 종류 · 이름 비우면 칸 안내 · 단위 칸은 없음(31 정함)
new_item = dialogs.split("export function askNewItem(")[1].split("export function askFolder")[0]
assert 'L("M02_FieldNeeded").replace("{label}", label)' in new_item
assert "need(kind, " in new_item and "need(name, " in new_item
panel = _text("M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts")
assert (
'label: L("M02_TreeUnit") }' in panel
and "optional: true"
not in panel.split("async function addItem")[1].split("async function rename")[0]
"askNewItem({" in panel.split("async function addItem")[1].split("async function rename")[0]
)
+10
View File
@@ -132,6 +132,16 @@ export const ui_locales_m2 = {
],
M02_TreePicked: ["{n}개 고름", "{n} selected"],
M02_TreeNewItem: ["새 항목", "New item"],
M02_NewItemKind: ["구조물 종류", "Structure type"],
M02_NewItemAt: ["넣을 곳: {value}", "Into: {value}"],
M02_NewItemCombo: ["일위대가", "Unit price"],
M02_NewItemNoCombo: [
"없음 — 표에서 나중에 골라도 됨",
"None — you can pick it later in the table",
],
M02_NewItemPickCombo: ["고르기", "Pick"],
M02_TreePickCombo: ["일위대가 고르기", "Pick unit price"],
M02_TableFold: ["표 접기 · 펴기", "Fold or unfold the table"],
M02_TreeCopyToMaster: ["마스터로 복사 ({n}개)", "Copy to master ({n})"],
M02_TreeCopyToCompany: ["회사로 복사 ({n}개)", "Copy to company ({n})"],
M02_TreeCopyToPersonal: ["개인으로 복사 ({n}개)", "Copy to personal ({n})"],