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

This commit is contained in:
2026-11-02 15:51:39 +09:00
13 changed files with 712 additions and 68 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ def edit_library_tree(
"""새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) ·
여럿 = `대상` → 새 나무 · 판 다르면 409."""
folder = _root(session, layer, owner, write=True)
return _call(folder, pre_folders.edit, body.model_dump(), kind)
return _call(folder, pre_folders.edit, body.model_dump(), kind, session)
@router.post("/layers/{layer}/{kind}-tree/paste")
+278
View File
@@ -0,0 +1,278 @@
"""M02 서고 나무 지우기 = 경고 뒤 지움 · 쓰는 곳 그 줄도 지움(PLAN 735-1 · 요구 574) —
일위대가(`precombo` SC) · 단가산출(`sangeun` SK) 한 벌.
미리 보기 `POST /layers/{층}/{kind}-tree/delete-check {대상}` → `{문서, 쓰는곳: [줄]}` —
줄 = {층, 주인, 어디, 종류, 문서, 이름, 키, 줄, 쓰기} · 종류 = 구조물 조합 · 일위대가 · 단가산출 ·
줄 = 담은로직 「N째」 · 「더한줄 N째」 · 「바탕」 · 모든 층 훑음(프로젝트 뺌 · 지우는 것끼리 뺌).
지우기(나무 고침 `op: delete` · `M02_Precombo_Folders.edit`) — 권한 밖 쓰는 곳 = 409
`{까닭, 막힘}` · 쓰는 곳이 있는데 `확인` 없음 = 409 `{까닭, 쓰는곳}` · 확인 = 쓰는 문서의 그 줄
지움(`store.write` = 서버 정본 다시 셈) → 문서 지움 · 기록 `{층}/_합친것/<첫 키>.json`(733-3 자리 ·
`지움` = 지운 문서 · 자리 · `고친곳` = 고친 문서 앞판 · 뒤판) · 못 쓰면 앞판으로 되돌리고 409.
되돌림(`undo` · `M02_Library_Same_Router` undo 길이 부름) = 문서 · 자리 · 고친 문서 앞판(그새 안
고친 것만). 프로젝트 사본 · B09 공사 = 굳힌 값이라 안 건드림.
바탕 지움(단가산출) = 바탕 없음 + 바탕 줄 덮개 · 뺀줄 · 고름 · 자재표시 · 열린입력 걷음 ·
더한줄 지움 = 뒤 `add:#k` 열쇠 당김.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
GONE = "_합친것"
USER_KINDS = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출"))
_ADD = re.compile(r"^add:#(\d+)(.*)$")
_PATCH = ("덮개", "고름", "자재표시")
def can_write(session: dict[str, Any] | None, at: refs.Here) -> bool:
if session is None:
return False
try:
return access.grant(session, at[0], at[1]).write
except HTTPException:
return False
def _hits(doc: dict[str, Any], rids: dict[str, str]) -> list[tuple[str, str]]:
"""문서 안 지울 참조 줄 — [(줄, 키)]."""
out = []
for field, head in (("담은로직", ""), ("더한줄", "더한줄 ")):
for at, one in enumerate(doc.get(field) or [], 1):
ref = one.get("참조") if isinstance(one, dict) else None
if isinstance(ref, dict) and refs.ref_id(ref) in rids:
out.append((f"{head}{at}째", rids[refs.ref_id(ref)]))
base = doc.get("바탕")
if isinstance(base, dict) and isinstance(base.get("참조"), dict):
if refs.ref_id(base["참조"]) in rids:
out.append(("바탕", rids[refs.ref_id(base["참조"])]))
return out
def _columns(folder: Path, memo: dict[Path, dict[str, str]]) -> dict[str, str]:
"""층 구조물 열 id → 구조물 이름(구조물 조합 줄 이름 · 층마다 한 번)."""
if folder not in memo:
try:
with store.at(folder):
cols = store._structure_columns()
memo[folder] = {c["id"]: tree.name_of(tree.path_of(c)) for c in cols}
except (store.StoreError, OSError, ValueError):
memo[folder] = {}
return memo[folder]
def users(session: dict[str, Any] | None, kind: str, gone: list[str]) -> list[dict[str, Any]]:
"""지금 층(`store.at`) 지울 문서를 쓰는 줄 — 모든 층(프로젝트 뺌 · 지우는 것끼리 뺌)."""
mine = refs.here()
rids = {refs.ref_id({"층": mine[0], "주인": mine[1], "키": k}): k for k in gone}
skip = {(store.root() / kind / f"{k}.json").resolve() for k in gone}
rows, memo = [], {}
for ukind, what in USER_KINDS:
for where, path in layers.layer_files(ukind):
if path.resolve() in skip:
continue
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
hits = _hits(doc, rids) if isinstance(doc, dict) else []
if not hits:
continue
at = refs.where(path.parent.parent)
ok = can_write(session, at)
name = doc.get("일위대가번") or doc.get("이름") or path.stem
if ukind == "labor" and (title := _columns(path.parent.parent, memo).get(path.stem)):
name = f"{title} · {name}"
for line, key in hits:
rows.append(
{
"층": at[0],
"주인": at[1],
"어디": where,
"종류": what,
"문서종류": ukind,
"문서": path.stem,
"이름": name,
"키": key,
"줄": line,
"쓰기": ok,
}
)
return rows
def _shift(key: str, dropped: list[int]) -> str | None:
"""`add:#k…` 열쇠 — 지운 더한줄이면 None · 뒤 줄은 당김 · 그 밖 그대로."""
hit = _ADD.match(key)
if not hit:
return key
k = int(hit[1])
if k in dropped:
return None
return f"add:#{k - sum(1 for d in dropped if d < k)}{hit[2]}"
def strip(doc: dict[str, Any], rids: set[str]) -> dict[str, Any]:
"""문서에서 `rids` 참조 줄을 뺌(순수) — 담은로직 · 더한줄(뒤 `add:#k` 당김) · 바탕."""
def hit(one: Any) -> bool:
ref = one.get("참조") if isinstance(one, dict) else None
return isinstance(ref, dict) and refs.ref_id(ref) in rids
out = {**doc}
if "담은로직" in doc:
out["담은로직"] = [one for one in doc.get("담은로직") or [] if not hit(one)]
added = doc.get("더한줄") or []
dropped = [at for at, one in enumerate(added) if hit(one)]
base_gone = hit(doc.get("바탕"))
if "더한줄" in doc:
out["더한줄"] = [one for one in added if not hit(one)]
if base_gone:
out["바탕"], out["열린입력"] = None, []
if not (dropped or base_gone):
return out
def keep(key: str) -> str | None:
if not key.startswith("add:"):
return None if base_gone else key # 바탕 줄 열쇠 — 바탕이 사라지면 걷음
return _shift(key, dropped)
for field in _PATCH:
if isinstance(doc.get(field), dict):
moved = {keep(str(k)): v for k, v in doc[field].items()}
out[field] = {k: v for k, v in moved.items() if k is not None}
if isinstance(doc.get("뺀줄"), list):
out["뺀줄"] = [k for k in (keep(str(x)) for x in doc["뺀줄"]) if k is not None]
return out
def _restore(done: list[dict[str, Any]]) -> None:
for one in reversed(done):
Path(one["파일"]).write_bytes(one["앞"])
def remove(
session: dict[str, Any] | None,
kind: str,
gone: list[str],
places: dict[str, Any],
sure: bool,
) -> str:
"""지금 층 `gone` 지움 — 막힘 · 확인 없음 409 · 쓰는 줄 지움 → 문서 지움 · 기록 → 기록 키."""
rows = users(session, kind, gone)
if blocked := [r for r in rows if not r["쓰기"]]:
names = " · ".join(f"{r['어디']} 「{r['이름']}」 ← {r['키']}" for r in blocked)
raise store.StoreError(
409, {"까닭": f"권한 밖 쓰는 곳이 있어 못 지움 — {names}", "막힘": blocked}
)
if rows and not sure:
raise store.StoreError(409, {"까닭": f"쓰는 곳 {len(rows)} — 확인 뒤 지움", "쓰는곳": rows})
mine = refs.here()
rids = {refs.ref_id({"층": mine[0], "주인": mine[1], "키": k}) for k in gone}
files = {}
for r in rows:
at = refs.folder_of(r["층"], r["주인"]) / r["문서종류"] / f"{r['문서']}.json"
files[at] = r
done: list[dict[str, Any]] = []
# ponytail: 여러 파일을 차례로 씀(잠금 없음) — 실패하면 앞서 고친 파일을 앞판 바이트로 되돌림
for path, r in files.items():
raw = path.read_bytes()
try:
with store.at(path.parent.parent):
store.write(
r["문서종류"], path.stem, store.version_of(raw), strip(json.loads(raw), rids)
)
except store.StoreError as error:
_restore(done)
raise store.StoreError(
409, {"까닭": f"{r['어디']} 「{r['이름']}」 줄을 못 지움 — {error.detail}"}
) from error
done.append(
{"파일": str(path), "앞": raw, "r": r, "뒤판": store.version_of(path.read_bytes())}
)
root = store.root()
docs = [
{"키": k, "문서": json.loads(p.read_bytes()), "자리": places.get(k)}
for k in gone
if (p := root / kind / f"{k}.json").is_file()
]
for one in docs:
(root / kind / f"{one['키']}.json").unlink()
name = _free(root / GONE, gone[0])
atomic_write_json(
root / GONE / f"{name}.json",
{
"키": name,
"종류": kind,
"지움": docs,
"고친곳": [
{
"층": d["r"]["층"],
"주인": d["r"]["주인"],
"종류": d["r"]["문서종류"],
"이름": d["r"]["문서"],
"문서": json.loads(d["앞"]),
"뒤판": d["뒤판"],
}
for d in done
],
"때": datetime.now().isoformat(timespec="seconds"),
"누가": (session or {}).get("name"),
},
)
return name
def _free(folder: Path, key: str) -> str:
"""기록 이름 — 그 키 기록이 이미 있으면(지운 키를 다시 씀) 「키~2」 …"""
name, n = key, 1
while (folder / f"{name}.json").exists():
n += 1
name = f"{key}~{n}"
return name
def undo(session: dict[str, Any], folder: Path, kind: str, rec: dict[str, Any]) -> dict[str, Any]:
"""지움 기록 되돌림 — 문서(키를 그새 남이 쓰면 409) · 나무 자리 · 고친 문서 앞판
(그새 안 고친 것 · 쓸 수 있는 곳만 · 못한 곳 = 까닭)."""
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
docs = rec.get("지움") or []
if taken := [d["키"] for d in docs if (folder / kind / f"{d['키']}.json").exists()]:
raise store.StoreError(
409, f"「{' · '.join(taken)}」 키를 그새 다른 문서가 씀 — 되돌릴 수 없음"
)
for d in docs:
atomic_write_json(folder / kind / f"{d['키']}.json", d["문서"])
with store.at(folder):
tree_doc, _ = pre_folders._read(kind)
places = {**(tree_doc.get("자리") or {}), **{d["키"]: d["자리"] for d in docs if d.get("자리")}}
atomic_write_json(folder / pre_folders.file_of(kind), {**tree_doc, "자리": places})
back, miss = 0, []
for one in rec.get("고친곳") or []:
at = (one.get("층"), one.get("주인"))
path = refs.folder_of(*at) / str(one.get("종류")) / f"{one.get('이름')}.json"
try:
if not can_write(session, at) or not path.is_file():
raise store.StoreError(403, "권한 밖 · 없음")
raw = path.read_bytes()
if store.version_of(raw) != one.get("뒤판"):
raise store.StoreError(409, "그새 고침 — 그 줄은 손으로 다시 담음")
with store.at(path.parent.parent):
store.write(str(one["종류"]), path.stem, store.version_of(raw), one["문서"])
back += 1
except store.StoreError as error:
miss.append(f"{one.get('종류')} {one.get('이름')} — {error.detail}")
return {"되돌린곳수": back, "못한곳": miss}
+60 -18
View File
@@ -7,7 +7,10 @@ GET /layers/{층}/{kind}-same — 이 층 + 위 층(회사 · 마스터) 같은
POST …/merge {남김, 지움[], 판} — 서버가 지문 다시 셈(다르면 409) · 쓰는 곳 참조를 남김으로
(쓰기 권한 닿는 곳만 · `store.write` 정본 다시 셈) · 남은 쓰는 곳이 있는 문서 = 안 지움
「남김 — 쓰는 곳 N」 · 나머지 = `{층}/_합친것/<키>.json` 으로 옮김 · 나무 자리 걷음
POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되돌림
POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되돌림 · 나무 지움 기록(735-1 `지움` 칸)이면
`M02_Library_Delete.undo`(문서 · 자리 · 고친 문서 앞판)
POST /layers/{층}/{kind}-tree/delete-check {대상} — 나무 지우기 미리 보기(735-1) · 지울 문서 ·
쓰는 줄
지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) ·
판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라
안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만.
@@ -27,7 +30,7 @@ from pydantic import BaseModel
from common_util.common_util_auth import verify_session
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_Library_Delete as deleting
from M02_MasterTemplete import M02_Library_Same as same
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Precombo_Folders as pre_folders
@@ -37,8 +40,8 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Library Same"])
Kind = Literal["precombo", "sangeun"]
GONE = "_합친것"
USER_KINDS = (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출"))
GONE = deleting.GONE
USER_KINDS = deleting.USER_KINDS
_ORDER = {"system": 0, "company": 1, "personal": 2}
@@ -54,6 +57,12 @@ class UndoBody(BaseModel):
키: str
class CheckBody(BaseModel):
"""나무 지우기 미리 보기 — 나무 고침 `대상` 꼴 `[{경로, 키?}]`."""
대상: list[dict[str, Any]]
def _bad(status: int, text: Any) -> store.StoreError:
return store.StoreError(status, text)
@@ -92,10 +101,7 @@ def _user_index() -> dict[str, list[tuple[Path, str, str]]]:
def _can_write(session: Session, at: refs.Here) -> bool:
try:
return access.grant(session, at[0], at[1]).write
except HTTPException:
return False
return deleting.can_write(session, at)
def _retarget(doc: dict[str, Any], olds: set[str], new: refs.Ref) -> dict[str, Any]:
@@ -144,17 +150,23 @@ def _gone_rows(folder: Path, kind: str) -> list[dict[str, Any]]:
rec = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
if rec.get("종류") == kind:
if rec.get("종류") != kind:
continue
if "지움" in rec: # 나무 지움(735-1) — 남김 없음 · 이름 = 지운 문서들
names = [(d.get("문서") or {}).get("이름") or d.get("키") for d in rec["지움"]]
row = {"이름": " · ".join(map(str, names)), "남김": None, "고친": rec.get("고친곳")}
else:
doc = rec.get("문서") or {}
out.append(
{
"키": rec.get("키"),
"이름": doc.get("이름") or rec.get("키"),
"남김": rec.get("남김"),
"때": rec.get("때"),
"옮긴곳수": len(rec.get("옮긴곳") or []),
}
)
row = {"이름": doc.get("이름") or rec.get("키"), "남김": rec.get("남김"), "고친": None}
out.append(
{
"키": path.stem,
"이름": row["이름"],
"남김": row["남김"],
"때": rec.get("때"),
"옮긴곳수": len(row["고친"] or rec.get("옮긴곳") or []),
}
)
return out
@@ -275,6 +287,10 @@ def undo(session: Session, folder: Path, kind: str, key: str) -> dict[str, Any]:
raise _bad(404, f"합친 것에 없음 「{key}」") from None
if rec.get("종류") != kind:
raise _bad(404, f"합친 것에 없음 「{key}」")
if "지움" in rec:
got = deleting.undo(session, folder, kind, rec)
rec_path.unlink()
return {"되돌림": key, **got, **find(folder, kind)}
path = folder / kind / f"{key}.json"
if path.exists():
raise _bad(409, f"「{key}」 키를 그새 다른 문서가 씀 — 되돌릴 수 없음")
@@ -328,6 +344,32 @@ def library_same_merge(
return _run(merge, session, _root(session, layer, owner, write=True), kind, body)
def check(session: Session, folder: Path, kind: str, picks: list[dict[str, Any]]) -> dict:
"""나무 지우기 미리 보기 — 나무 고침을 셈만(저장 안 함) → 지울 문서 · 쓰는 줄."""
with store.at(folder):
tree_doc, _ = pre_folders._read(kind)
try:
_, note = pre_folders.apply_many(
tree_doc, {"op": "delete", "대상": picks}, pre_folders._keys(kind)
)
except pre_folders.tree.TreeError as e:
raise _bad(e.status, e.detail) from e
gone = note.get("지운문서") or []
return {"문서": gone, "쓰는곳": deleting.users(session, kind, gone)}
@router.post("/layers/{layer}/{kind}-tree/delete-check")
def library_delete_check(
layer: Layer,
kind: Kind,
body: CheckBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""나무 지우기 미리 보기(735-1) — `{문서, 쓰는곳}`(`M02_Library_Delete.users` 줄)."""
return _run(check, session, _root(session, layer, owner, write=True), kind, body.대상)
@router.post("/layers/{layer}/{kind}-same/undo")
def library_same_undo(
layer: Layer,
@@ -177,7 +177,13 @@ export interface PrecomboTree {
판: string;
폴더: string[][];
자리: Record<string, string[]>;
알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] };
알림?: {
op: string;
지운문서?: string[];
만든문서?: string[];
바뀐이름?: string[];
기록?: string;
};
}
const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string =>
owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner);
@@ -191,12 +197,25 @@ export const editPrecomboTree = (
edit: Pick<TreeEdit, "경로" | "이름" | "새경로" | "키"> & {
op: "add-folder" | "rename" | "move" | "delete";
대상?: TreeRef[];
/** 지우기 — 쓰는 곳 경고를 본 뒤 그 줄까지 지움(735-1) */
확인?: boolean;
},
판: string,
owner: string | null,
kind: LibraryKind = "precombo",
): Promise<PrecomboTree> =>
call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) });
/** 지우기 미리 보기(735-1) — 지울 문서 키 · 쓰는 줄(`M02_Library_Delete.users`) */
export const checkLibraryDelete = <T>(
kind: LibraryKind,
layer: Layer,
owner: string | null,
picks: TreeRef[],
): Promise<T> =>
call(libTree(kind, layer, owner, "/delete-check"), {
method: "POST",
body: JSON.stringify({ 대상: picks }),
});
/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */
export const pasteLibraryTree = (
kind: LibraryKind,
@@ -0,0 +1,109 @@
/* =============================================================================
* M02_MasterTemplete_Library_Delete.ts
* 서고 나무 지우기 경고(735-1 · 요구 574) — 좌측 일위대가 · 단가산출 나무 [지우기] 앞에 부름.
* 서버 미리 보기(`{kind}-tree/delete-check`) → 쓰는 곳 없음 = 원래 확인 글 · 있음 = 경고 「이 항목을 쓰는 곳 N —
* 지우면 그 줄도 지워짐」 + 표(종류 · 이름 · 키 · 줄 · 지울 키 · 층) + [삭제] [취소] · 권한 밖이 있으면
* 못 지움 알림 + 그 목록만. 확인 = 나무 고침 몸 `확인: true`(서버가 그 줄 지움 · 금액 다시 셈 · 되돌림 기록).
* 되돌림 = 나무 맨 위 메뉴 [중복 찾기] › 합친 것 · 지운 것.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { askConfirm } from "@ui/ui_template_modal";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import {
checkLibraryDelete,
type Layer,
type LibraryKind,
type TreeRef,
} from "./M02_MasterTemplete_Api_Fetch";
const TEXT = {
Title: ["쓰는 곳이 있음", "In use"],
Warn: [
"이 항목을 쓰는 곳 {n} — 지우면 그 줄도 지워짐(금액 다시 셈)",
"Used in {n} place(s) — deleting also removes those lines (amounts recalculated)",
],
Blocked: [
"권한 밖 쓰는 곳 {n} — 지울 수 없음(그 층 주인이 먼저 줄을 뺌)",
"{n} use(s) outside your permission — cannot delete (their owner must remove the lines first)",
],
Note: [
"프로젝트 사본 · B09 공사는 굳힌 값이라 영향 없음 · 되돌림 = 나무 맨 위 메뉴 [중복 찾기] › 되돌림",
"Project copies and B09 estimates keep frozen values · Undo = tree top menu [Find duplicates] › Undo",
],
Kind: ["종류", "Type"],
Name: ["이름", "Name"],
Key: ["키", "Key"],
Line: ["줄", "Line"],
Gone: ["지울 키", "Deleting"],
Layer: ["층", "Layer"],
Ok: ["확인", "OK"],
} as const;
const tx = (key: keyof typeof TEXT, n = 0): string =>
String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n));
/** 쓰는 줄 하나(서버 `M02_Library_Delete.users`) */
export interface DeleteUser {
층: Layer;
주인: string | null;
어디: string;
종류: string;
문서: string;
이름: string;
키: string;
줄: string;
쓰기: boolean;
}
interface DeleteCheck {
문서: string[];
쓰는곳: DeleteUser[];
}
const table = (rows: DeleteUser[]): HTMLElement => {
const columns = [
{ label: tx("Kind"), width: "96px" },
{ label: tx("Name") },
{ label: tx("Key"), width: "112px" },
{ label: tx("Line"), width: "88px" },
{ label: tx("Gone"), width: "112px" },
{ label: tx("Layer"), width: "96px" },
];
const cells: Cell[][] = rows.map((r) => [r.종류, r.이름, r.문서, r.줄, r.키, r.어디]);
return el("div", {
attrs: { "data-library-delete": "" },
children: [createTable(columns, cells, { density: "data" }), createText(tx("Note"), "muted")],
});
};
/** 지우기 물음 — 쓰는 곳 없음 = `ask` 글 · 있음 = 경고 + 표 · 권한 밖 = 알림만(false).
* `{ok, sure}` — ok = 지움 · sure = 쓰는 곳까지 지움(나무 고침 `확인`). */
export async function askLibraryDelete(
kind: LibraryKind,
layer: Layer,
owner: string | null,
picks: TreeRef[],
ask: string,
okLabel: string,
): Promise<{ ok: boolean; sure: boolean }> {
const got = await checkLibraryDelete<DeleteCheck>(kind, layer, owner, picks);
const used = got.쓰는곳;
if (!used.length)
return { ok: await askConfirm({ message: ask, okLabel, danger: true }), sure: false };
const blocked = used.filter((r) => !r.쓰기);
if (blocked.length) {
const message = tx("Blocked", blocked.length);
await askConfirm({ title: tx("Title"), message, okLabel: tx("Ok"), detail: table(blocked) });
return { ok: false, sure: false };
}
const message = `${ask}\n${tx("Warn", used.length)}`;
const ok = await askConfirm({
title: tx("Title"),
message,
okLabel,
danger: true,
detail: table(used),
});
return { ok, sure: ok };
}
@@ -3,7 +3,8 @@
* 서고 [중복 찾기](733-3 · 요구서_572 3장 4) — 좌측 일위대가 · 단가산출 나무 메뉴에서 엶.
* 창 = 같은 내용 묶음(이 층 + 위 층 · 서버 `GET {kind}-same`) · 묶음마다 꼬리표(내용 같음 · 수량만 다름) ·
* 문서 줄(층 · 키 · 이름 · 구분 · 쓰는 곳 수) · [남김] 고르기 · [합치기](물음 → 서버 `merge` · 이 층 나머지 문서를
* 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`).
* 지움 · 쓰는 곳 참조를 남김으로) · 결과(합침 · 권한 밖 남김) · 합친 것 목록 [되돌림](`undo`) ·
* 나무 지우기 기록(735-1 · 남김 없음 = 「지움」 · 고친 쓰는 곳 수)도 같은 목록 · 같은 [되돌림].
* 권한 = 그 층 쓰기(서버가 봄) · 판 다르면 409 → 다시 찾음.
* ========================================================================== */
@@ -54,8 +55,9 @@ const TEXT = {
"남김 — 「{key}」 쓰는 곳 {n}(권한 밖 · 못 옮김): {list}",
"Kept “{key}” — {n} user(s) not moved: {list}",
],
Gone: ["합친 것(되돌림)", "Merged (undo)"],
GoneNone: ["합친 것 없음", "Nothing merged"],
Gone: ["합친 것 · 지운 것(되돌림)", "Merged · deleted (undo)"],
GoneNone: ["합친 것 · 지운 것 없음", "Nothing merged or deleted"],
Deleted: ["지움", "Deleted"],
When: ["때", "When"],
Into: ["남김", "Kept as"],
Undo: ["되돌림", "Undo"],
@@ -63,6 +65,10 @@ const TEXT = {
"「{key}」 되돌림 — 문서를 되살리고 옮긴 쓰는 곳 {n} 을 다시 이 문서로",
"Undo “{key}” — restore it and point its {n} user(s) back",
],
AskUndoDeleted: [
"「{key}」 되돌림 — 지운 문서를 되살리고 쓰는 곳 {n} 에서 지운 줄을 다시 넣음(그새 고친 곳은 건너뜀)",
"Undo “{key}” — restore deleted doc(s) and put the lines back in {n} user(s) (skips ones edited since)",
],
Undone: ["되돌림 — 쓰는 곳 {n}", "Undone — users {n}"],
Stale: ["그새 바뀜 — 다시 찾음", "Changed meanwhile — searching again"],
} as const;
@@ -96,7 +102,8 @@ export interface SameGroup {
export interface GoneRow {
키: string;
이름: string;
남김: SameRef;
/** null = 나무 지우기 기록(735-1) */
남김: SameRef | null;
때: string;
옮긴곳수: number;
}
@@ -195,7 +202,8 @@ export function openLibrarySame(opt: SameOptions): void {
};
const undo = async (row: GoneRow): Promise<void> => {
const message = tx("AskUndo", { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 });
const ask = row.남김 ? "AskUndo" : "AskUndoDeleted";
const message = tx(ask, { key: `${row.키} ${row.이름}`, n: row.옮긴곳수 });
if (!(await askConfirm({ message, okLabel: tx("Undo"), title: tx("Title") }))) return;
await run(
() => post<SameView>(path(opt.kind, opt.layer, opt.owner, "/undo"), { 키: row.키 }),
@@ -273,7 +281,7 @@ export function openLibrarySame(opt: SameOptions): void {
const cells: Cell[][] = rows.map((r) => [
r.키,
r.이름,
`${tx(r.남김.층)} ${r.남김.키}`,
r.남김 ? `${tx(r.남김.층)} ${r.남김.키}` : tx("Deleted"),
r.때.replace("T", " "),
createButton({
label: tx("Undo"),
@@ -75,6 +75,8 @@ class TreeBody(BaseModel):
판: str
# 여러 개 옮기기 · 지우기(PLAN 30-2) — `[{경로, 키?}]` · 있으면 이것마다 차례로 · 저장 한 번
대상: list[dict[str, Any]] | None = None
# 서고 지우기(735-1) — 쓰는 곳 경고를 본 뒤 그 줄까지 지움
확인: bool = False
class PasteBody(BaseModel):
@@ -4,7 +4,8 @@
* 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`).
* 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버
* (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] ·
* [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기.
* [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 경고 표 뒤 그 줄째 · `Library_Delete`) · 빈 자리 = 뿌리 메뉴 ·
* 끌어 옮기기.
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
@@ -13,7 +14,7 @@
* ========================================================================== */
import { isCtrlKey } from "../A00_Common/key_shortcut";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -36,6 +37,7 @@ import {
type LibraryKind,
type PrecomboTree,
} from "./M02_MasterTemplete_Api_Fetch";
import { askLibraryDelete } from "./M02_MasterTemplete_Library_Delete";
import { nameDialog } from "./M02_MasterTemplete_Library_List";
import { openLibrarySame, sameLabel } from "./M02_MasterTemplete_Library_Same";
import type { Root } from "./M02_MasterTemplete_Layer";
@@ -256,7 +258,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
/* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */
const pickedTargets = (): Target[] =>
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
/** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */
/** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 경고 표 뒤 그 줄째(735-1) */
const remove = async (list: Target[]): Promise<void> => {
if (!list.length || !opt.scope().canWrite) return;
const [one] = list;
@@ -268,8 +270,14 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
: L("M02_TreeDeleteFolder")
.replace("{value}", one.path[one.path.length - 1])
.replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0));
if (!(await showConfirmDialog(ask, L("M02_Delete")))) return;
await edit({ op: "delete", 대상: list.map(refOf) });
const at = opt.scope();
const picks = list.map(refOf);
try {
const got = await askLibraryDelete(kind, at.layer, at.owner, picks, ask, L("M02_Delete"));
if (got.ok) await edit({ op: "delete", 대상: picks, ...(got.sure ? { 확인: true } : {}) });
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
};
const copy = (list: Target[]): void => {
if (!list.length) return;
+12 -32
View File
@@ -4,7 +4,8 @@
개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는
문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면
409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) —
모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음).
쓰는 곳은 `M02_Library_Delete`(735-1 · 권한 밖 409 · `확인` 뒤 그 줄 지움 · 되돌림 기록 ·
지우는 것끼리 참조는 막지 않음).
붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 ·
못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`).
이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌.
@@ -18,12 +19,11 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Delete as deleting
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
OPS = ("add-folder", "rename", "move", "delete")
USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴
def file_of(kind: str) -> str:
@@ -175,33 +175,13 @@ def apply_many(
return doc, note
def _refuse_used(kind: str, gone: list[str]) -> None:
"""지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 —
모든 층 훑음(프로젝트 뺌)."""
layer, owner = refs.here()
rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone}
mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone}
found = []
for what_kind, what in USERS:
for at, path in layers.layer_files(what_kind):
if path.resolve() in mine:
continue
try:
doc = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids})
if hit:
name = doc.get("일위대가번") or doc.get("이름") or path.stem
found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}")
if found:
raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found))
# ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임
# (키 재사용을 막을 때 함께 고침)
def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]:
"""지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409."""
def edit(
body: dict[str, Any], kind: str = "precombo", session: dict[str, Any] | None = None
) -> dict[str, Any]:
"""지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째 · 쓰는 곳 = `deleting.remove` · 몸 `확인`) →
새 나무 + 알림(지우기 = `기록` 되돌림 키) · 판 다르면 409."""
doc, 판 = _read(kind)
if 판 != str(body.get("판") or ""):
raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판})
@@ -211,10 +191,10 @@ def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]:
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
gone = note.get("지운문서") or []
if gone:
_refuse_used(kind, gone) # 모두 검사 뒤 한 번에
for key in gone:
(store.root() / kind / f"{key}.json").unlink(missing_ok=True)
if gone: # 모두 검사 뒤 한 번에
note["기록"] = deleting.remove(
session, kind, gone, _places(doc, keys), bool(body.get("확인"))
)
atomic_write_json(store.root() / file_of(kind), out)
return {**view(kind), "알림": note}
+10 -5
View File
@@ -3,7 +3,7 @@
임시 storage.
`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 ·
폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 ·
폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 확인 없이는 409 + 목록 ·
지우는 것끼리 참조는 됨) ·
`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본.
"""
@@ -64,15 +64,20 @@ def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨
assert _files("sangeun") == [] and sk.now["폴더"] == []
def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811
def test_바깥에서_쓰면_확인_없이는_409_목록_아무것도_안_지움(client: TestClient): # noqa: F811
a = _sk(client, "바탕")
body = {"문서": {"이름": "쓰는 일위대가", "단위": "㎡", "담은로직": [{"참조": {**MINE, "키": a}}]}}
body = {
"문서": {"이름": "쓰는 일위대가", "단위": "㎡", "담은로직": [{"참조": {**MINE, "키": a}}]}
}
assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200
sk = _sk_tree(client)
sk.do(op="add-folder", 경로=[], 이름="묶음")
sk.do(op="move", 키=a, 새경로=["묶음"])
detail = sk.do(409, op="delete", 경로=["묶음"])["detail"]
assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail
detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] # 735-1 — 확인 뒤 그 줄째 지움
assert "쓰는 곳" in detail["까닭"]
assert [(r["이름"], r["키"], r["줄"]) for r in detail["쓰는곳"]] == [
("쓰는 일위대가", a, "1째")
]
assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]]
@@ -0,0 +1,178 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""서고 나무 지우기 = 경고 뒤 지움 · 쓰는 곳 그 줄도 지움(PLAN 735-1 · 요구 574) — 임시 storage.
미리 보기(쓰는 줄 = 종류 · 이름 · 키 · 줄) · 확인 없으면 409 목록 · 확인 = 그 줄 지움
(금액 다시 셈) + 문서 지움 + `_합친것/` 기록 · 되돌림(문서 · 자리 · 고친 문서 앞판 ·
그새 고친 문서는 건너뜀) ·
권한 밖 409(아무것도 안 바뀜) · 폴더 통째 = 한 기록 · 단가산출 바탕 · 더한줄 열쇠 당김(`strip`).
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_Library_Delete as deleting
from M02_MasterTemplete import M02_Library_Same_Router as same_router
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401
USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"}
TREE = "/api/m02/layers/personal/precombo-tree"
SAME = "/api/m02/layers/personal/precombo-same"
SK_TREE = "/api/m02/layers/personal/sangeun-tree"
SK_SAME = "/api/m02/layers/personal/sangeun-same"
FIX = {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": {"손": 1}}
@pytest.fixture()
def api(world):
app = FastAPI()
app.include_router(labor_router.router)
app.include_router(same_router.router)
app.dependency_overrides[verify_session] = lambda: USER
return TestClient(app)
def _sc(folder, name, lines):
with store.at(folder):
return refs.create("precombo", {"이름": name, "단위": "m", "담은로직": lines})["이름"]
def _labor(folder, lines):
with store.at(folder):
store.write("labor", "x1", "", {"열": "x1", "담은로직": lines, "자재": []})
def _read(folder, kind, key):
with store.at(folder):
return store.read(kind, key)["문서"]
def _sum(folder):
return _read(folder, "labor", "x1")["계산값"]["반영합"]["계"]
def _tree(api, url=TREE, **body):
판 = api.get(url).json()["판"]
return api.post(url, json={"판": 판, **body})
def _world(world):
"""단가산출 하나(운반 3000 · 1 단위)를 구조물 조합 x1(2 단위) · 일위대가가 물게."""
me = world["personal"]
line = {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 3000}}, "수량": 1}
with store.at(me):
doc = {"이름": "임시 단가산출", "단위": "m", "바탕": None, "더한줄": [line]}
sk = refs.create("sangeun", doc)["이름"]
top = _sc(me, "위 일위대가", [{"참조": {**MINE, "키": sk}}, FIX])
_labor(me, [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}, FIX])
return me, sk, top
def test_미리보기_확인없으면_409_확인하면_그줄_지움_금액_다시셈_되돌림(api, world):
me, sk, top = _world(world)
before = _sum(me)
assert before == pytest.approx(5000 + 2 * 3000)
got = api.post(SK_TREE + "/delete-check", json={"대상": [{"키": sk}]})
assert got.status_code == 200, got.text
rows = got.json()["쓰는곳"]
assert got.json()["문서"] == [sk]
assert sorted((r["종류"], r["이름"], r["키"], r["줄"], r["쓰기"]) for r in rows) == [
("구조물 조합", "x1", sk, "1째", True),
("일위대가", "위 일위대가", sk, "1째", True),
]
res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}])
assert res.status_code == 409 and len(res.json()["detail"]["쓰는곳"]) == 2
assert (me / "sangeun" / f"{sk}.json").is_file()
res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}], 확인=True)
assert res.status_code == 200, res.text
assert res.json()["알림"]["기록"] == sk
assert not (me / "sangeun" / f"{sk}.json").exists()
assert [x.get("고정", {}).get("이름") for x in _read(me, "labor", "x1")["담은로직"]] == ["운반"]
assert len(_read(me, "precombo", top)["담은로직"]) == 1
assert _sum(me) == pytest.approx(5000) # 서버가 다시 셈
gone = api.get(SK_SAME).json()["합친것"]
assert [(g["키"], g["남김"], g["옮긴곳수"]) for g in gone] == [(sk, None, 2)]
back = api.post(SK_SAME + "/undo", json={"키": sk})
assert back.status_code == 200, back.text
assert back.json()["되돌린곳수"] == 2 and back.json()["못한곳"] == []
assert (me / "sangeun" / f"{sk}.json").is_file()
assert _read(me, "labor", "x1")["담은로직"][0]["참조"]["키"] == sk
assert _sum(me) == pytest.approx(before)
assert api.get(SK_SAME).json()["합친것"] == []
def test_쓰는곳_없으면_그냥_지움_기록은_남음(api, world):
me = world["personal"]
sc = _sc(me, "혼자", [FIX])
assert api.post(TREE + "/delete-check", json={"대상": [{"키": sc}]}).json()["쓰는곳"] == []
assert _tree(api, op="delete", 대상=[{"키": sc}]).status_code == 200
assert not (me / "precombo" / f"{sc}.json").exists()
assert api.post(SAME + "/undo", json={"키": sc}).status_code == 200
assert (me / "precombo" / f"{sc}.json").is_file()
def test_권한_밖이면_409_목록_아무것도_안_바뀜(api, world, monkeypatch):
me, sk, _top = _world(world)
monkeypatch.setattr(deleting, "can_write", lambda session, at: False)
res = _tree(api, SK_TREE, op="delete", 대상=[{"키": sk}], 확인=True)
assert res.status_code == 409
detail = res.json()["detail"]
assert "권한 밖" in detail["까닭"] and "x1" in detail["까닭"] and sk in detail["까닭"]
assert {r["이름"] for r in detail["막힘"]} == {"x1", "위 일위대가"}
assert (me / "sangeun" / f"{sk}.json").is_file()
assert len(_read(me, "labor", "x1")["담은로직"]) == 2
def test_폴더_통째_한_기록_되돌림_자리까지_그새_고친_문서는_건너뜀(api, world):
me = world["personal"]
a = _sc(me, "가", [FIX])
b = _sc(me, "나", [FIX])
_labor(me, [{"참조": {**MINE, "키": a}, "수량": {"손": 1}}, FIX])
other = _sc(me, "다른", [{"참조": {**MINE, "키": b}}, FIX])
assert _tree(api, op="add-folder", 경로=[], 이름="묶음").status_code == 200
for key in (a, b):
assert _tree(api, op="move", 키=key, 새경로=["묶음"]).status_code == 200
check = api.post(TREE + "/delete-check", json={"대상": [{"경로": ["묶음"]}]}).json()
assert sorted(check["문서"]) == [a, b]
assert sorted(r["키"] for r in check["쓰는곳"]) == [a, b]
res = _tree(api, op="delete", 대상=[{"경로": ["묶음"]}], 확인=True)
assert res.status_code == 200, res.text
name = res.json()["알림"]["기록"]
assert [g["키"] for g in api.get(SAME).json()["합친것"]] == [name]
with store.at(me): # 그새 「다른」 을 고침 → 그 문서는 앞판으로 안 돌림
have = store.read("precombo", other)
store.write("precombo", other, have["판"], {**have["문서"], "이름": "다른 고침"})
back = api.post(SAME + "/undo", json={"키": name}).json()
assert back["되돌린곳수"] == 1 and "그새 고침" in back["못한곳"][0]
assert (me / "precombo" / f"{a}.json").is_file() and (me / "precombo" / f"{b}.json").is_file()
assert api.get(TREE).json()["자리"] == {a: ["묶음"], b: ["묶음"]}
assert _read(me, "labor", "x1")["담은로직"][0]["참조"]["키"] == a
def test_단가산출_바탕_더한줄_지움_열쇠_당김():
gone = {**MINE, "키": "SK-000002"}
keep = {**MINE, "키": "SK-000003"}
doc = {
"바탕": {"참조": gone},
"열린입력": ["높이"],
"더한줄": [{"참조": gone, "수량": 1}, {"참조": keep, "수량": 1}, {"종류": "로직"}],
"덮개": {"labor:A#1": {"qty": 1}, "add:#1": {"배율": 2}, "add:#2/labor:B#1": {"qty": 3}},
"뺀줄": ["labor:A#2", "add:#0"],
"고름": {"add:#2/material:C#1": "MT1"},
"자재표시": {},
}
out = deleting.strip(doc, {refs.ref_id(gone)})
assert out["바탕"] is None and out["열린입력"] == []
assert [x.get("참조") for x in out["더한줄"]] == [keep, None]
assert out["덮개"] == {"add:#0": {"배율": 2}, "add:#1/labor:B#1": {"qty": 3}}
assert out["뺀줄"] == []
assert out["고름"] == {"add:#1/material:C#1": "MT1"}
assert deleting.strip(doc, set()) == doc # 안 걸리면 그대로
+9
View File
@@ -100,6 +100,15 @@
font-weight: 700;
color: var(--color-text);
}
/* 확인창 덧 내용(표 · `detail`) — 넓은 판 · 넘치면 안에서 스크롤 */
.ui-confirm__panel.ui-confirm__panel--wide {
max-width: min(720px, 92vw);
}
.ui-confirm__detail {
max-height: 40vh;
overflow: auto;
margin-bottom: var(--spacing-16);
}
.ui-confirm__input {
box-sizing: border-box;
width: 100%;
+6
View File
@@ -105,6 +105,8 @@ export interface AskOptions {
cancelLabel?: string;
/** 위험 확인 — 확인 단추 빨강 */
danger?: boolean;
/** 덧 내용(표 등) — 글 아래 · 있으면 판이 넓어지고 넘치면 안에서 스크롤 */
detail?: HTMLElement;
}
/** 갈래 묻기 단추 하나 — 누르면 `value` · 맨 뒤 단추가 주 단추(filled) */
@@ -126,6 +128,10 @@ export function confirmPanel(
const panel = el("div", { className: "ui-confirm__panel", attrs: { role: "dialog" } });
if (opts.title) panel.append(el("h3", { className: "ui-confirm__title", text: opts.title }));
panel.append(el("p", { className: "ui-confirm__message", text: opts.message }));
if (opts.detail) {
panel.classList.add("ui-confirm__panel--wide");
panel.append(el("div", { className: "ui-confirm__detail", children: [opts.detail] }));
}
let input: HTMLInputElement | null = null;
if (opts.field) {
const box = el("input", {