From 1871b6ffc2a06985f72c07081b8d1a4f507d5c73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:20:47 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B4=EB=93=9C=20[=EC=95=8C=EC=95=BD]=20?= =?UTF-8?q?=EC=BC=AC=EB=81=94=20=E2=80=94=20=EC=95=84=EB=9E=98=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=A4=84=20=EA=B8=B0=EB=B3=B8=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20=EA=B3=84=EC=A0=95=20=EC=B7=A8=ED=96=A5=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=C2=B7=20=EC=83=81=EC=84=B8=EB=8A=94=20=EB=8A=98=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=84=20(PLAN=2039-4=20D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/b_page_state.ts | 2 ++ .../M02_MasterTemplete_ItemCards.ts | 18 ++++++++++++++++++ .../M02_MasterTemplete_Structure.css | 5 +++++ resources/tester/test_m02_card_sheet_edit.py | 14 ++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index cca13de9d..9f9e3ae48 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -62,6 +62,8 @@ export const STATE_REGISTRY = { /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, + /** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */ + "m02-card-pills": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index fa329afec..3b3a24b3f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -13,6 +13,7 @@ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { LinkOptions, SpreadsheetHandle, @@ -62,6 +63,8 @@ const TEXT = { readFailed: "{value} 파일을 못 읽음 — {why}", discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", sheetHelp: "단축키 — 칸을 고르고 바로 침", + pills: "알약", + pillsTip: "아래 알약 줄 보임 · 숨김", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -221,6 +224,20 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return card; }; const sheets = [sheetCard("area"), sheetCard("material")]; + // 면적산출 카드 [알약] — 아래 알약 줄 보임 · 숨김(기본 숨김 · 계정 취향 · 상세는 늘 보임) + const pills = createButton({ label: TEXT.pills, variant: "ghost", size: "sm" }); + pills.title = TEXT.pillsTip; + const paintPills = (shown: boolean): void => { + pills.setAttribute("aria-pressed", String(shown)); + sheets[0].host.classList.toggle("is-pills-off", !shown); + }; + pills.addEventListener("click", () => { + const shown = pills.getAttribute("aria-pressed") !== "true"; + writeStateRaw("m02-card-pills", String(shown)); + paintPills(shown); + }); + paintPills(readStateRaw("m02-card-pills") === "true"); + sheets[0].shell.tools.prepend(pills); const cards: Card[] = [info, ...sheets]; const defaults: Record = { structure: TEXT.edit, @@ -244,6 +261,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); } + pills.hidden = !sheetEdit; }; const drop = (): void => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index b44d120e2..57eaacbce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -247,3 +247,8 @@ .m02-struct__sheet-grid .ss-links__list { max-height: 8rem; } + +/* 면적산출 카드 [알약] 끔(기본) — 아래 알약 줄만 숨김 · 연결 · 칸 세모는 그대로 */ +.m02-struct__sheet.is-pills-off .ss-links { + display: none; +} diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index a4b3e62c6..6dbe46cf5 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -75,3 +75,17 @@ def test_저장_안_한_카드_고침_물음(): assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main + + +def test_면적산출_카드_알약_줄_켬끔_기본_숨김(): + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in cards # 없으면 숨김 + assert 'writeStateRaw("m02-card-pills", String(shown));' in cards + assert 'sheets[0].host.classList.toggle("is-pills-off", !shown);' in cards + assert "pills.hidden = !sheetEdit;" in cards # 고칠 수 있는 카드만(보기만은 알약 줄 없음) + css = _text(M02 / "M02_MasterTemplete_Structure.css") + assert re.search(r"\.m02-struct__sheet\.is-pills-off \.ss-links \{\s*display: none;", css) + state = _text(ROOT / "A00_Common" / "b_page_state.ts") + assert '"m02-card-pills": { bucket: "pref", scope: "global" },' in state + # 상세(전체 편집기)는 켬끔 없음 — 늘 보임 + assert "is-pills-off" not in _text(M02 / "M02_MasterTemplete_UI_Main.ts") From 8fde124df5ab88c22890733e5187742fd5a0e7b7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:21:12 +0900 Subject: [PATCH 02/16] =?UTF-8?q?refactor(M02):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20M02=5FMaterial=5FPick.ts(pickMaterial=20=C2=B7=20Ma?= =?UTF-8?q?terialItem)=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_Materials.ts | 68 +--------------- .../M02_MasterTemplete_Matset.ts | 2 +- M02_MasterTemplete/M02_Material_Pick.ts | 77 +++++++++++++++++++ 3 files changed, 80 insertions(+), 67 deletions(-) create mode 100644 M02_MasterTemplete/M02_Material_Pick.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts index 809c92d72..93697e6fb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Materials.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Materials.ts @@ -5,15 +5,7 @@ * (서버 `branchMaterialCombo`) · 원래 조합은 그대로. 빼는 자재가 칸에 이어져 있으면 확인 후 연결도 풂. * ========================================================================== */ -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; -import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { LinkItem } from "../A00_Common/spreadsheet/spreadsheet_links"; import type { CellLink } from "../A00_Common/spreadsheet/spreadsheet_types"; import { @@ -22,6 +14,7 @@ import { type MaterialCombo, type MaterialItem, } from "./M02_MasterTemplete_Api_Fetch"; +import { pickMaterial } from "./M02_Material_Pick"; import { askBranch, branchName, branchToast } from "./M02_MasterTemplete_Combo"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; @@ -51,12 +44,6 @@ const TEXT = { askAlone: "이 자재 조합은 모든 프로젝트가 같이 씀 — 고친 내용으로 새 자재 조합을 만들고 이 열만 새 조합을 가리킴 · 같은 내용이면 있는 조합을 씀", failed: "자재 조합을 못 고침 — {value}", - pickTitle: "자재 추가", - pickClose: "닫기", - search: "자재 이름 · 규격 · 코드", - searching: "찾는 중…", - noMatch: "맞는 자재 없음", - searchFailed: "자재 목록을 못 받음", }; const fill = (text: string, values: Record): string => @@ -122,57 +109,6 @@ export interface MaterialPanelOptions { onApplied?: () => void; } -/** 자재 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(좌측 자재 조합 상세도 씀) */ -export function pickMaterial(done: (item: MaterialItem) => void): void { - openModal({ - title: TEXT.pickTitle, - closeLabel: TEXT.pickClose, - dialogClass: "m02-modal m02-pick", - mount: (body, close) => { - const search = createInputField({ placeholder: TEXT.search, type: "search" }); - const list = el("div", { className: "m02-pick__list" }); - let seq = 0; - const paint = async (): Promise => { - const mine = ++seq; - list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); - try { - const hits = await searchMaterials(search.input.value.trim()); - if (mine !== seq) return; - list.replaceChildren( - ...(hits.length - ? hits.map((m) => { - const row = el("button", { - className: "m02-pick__item", - attrs: { type: "button", "data-material": m.코드 }, - children: [ - el("span", { className: "m02-pick__key", text: m.코드 }), - `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), - ], - }); - row.addEventListener("click", () => { - done(m); - close(); - }); - return row; - }) - : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), - ); - } catch { - if (mine === seq) showToast(TEXT.searchFailed, "error"); - } - }; - let timer = 0; - search.input.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void paint(), 250); - }); - body.append(search.root, list); - void paint(); - search.input.focus(); - }, - }); -} - /** 옆 판 — 이 열에 자재 조합이 없어도 판은 뜸(더하면 새로 만듦) */ export function buildMaterialPanel(opts: MaterialPanelOptions): MaterialPanel { let key = opts.state.key; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.ts b/M02_MasterTemplete/M02_MasterTemplete_Matset.ts index 931e58d67..03f6367a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.ts @@ -22,7 +22,7 @@ import { StaleError, type MaterialCombo, } from "./M02_MasterTemplete_Api_Fetch"; -import { pickMaterial } from "./M02_MasterTemplete_Materials"; +import { pickMaterial } from "./M02_Material_Pick"; import { tm } from "./M02_MasterTemplete_Matset_Text"; export interface MatsetSectionOptions { diff --git a/M02_MasterTemplete/M02_Material_Pick.ts b/M02_MasterTemplete/M02_Material_Pick.ts new file mode 100644 index 000000000..8b0e604b5 --- /dev/null +++ b/M02_MasterTemplete/M02_Material_Pick.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * M02_Material_Pick.ts + * M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀). + * 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`. + * ========================================================================== */ + +import { createInputField, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; + +/** 고른 자재 한 줄 — M01 자재(키 `MT`) */ +export interface MaterialItem { + 코드: string; + 이름: string | null; + 규격: string | null; + 단위: string | null; +} + +const TEXT = { + pickTitle: "자재 추가", + pickClose: "닫기", + search: "자재 이름 · 규격 · 코드", + searching: "찾는 중…", + noMatch: "맞는 자재 없음", + searchFailed: "자재 목록을 못 받음", +}; + +/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */ +export function pickMaterial(done: (item: MaterialItem) => void): void { + openModal({ + title: TEXT.pickTitle, + closeLabel: TEXT.pickClose, + dialogClass: "m02-modal m02-pick", + mount: (body, close) => { + const search = createInputField({ placeholder: TEXT.search, type: "search" }); + const list = el("div", { className: "m02-pick__list" }); + let seq = 0; + const paint = async (): Promise => { + const mine = ++seq; + list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); + try { + const hits = await searchMaterials(search.input.value.trim()); + if (mine !== seq) return; + list.replaceChildren( + ...(hits.length + ? hits.map((m) => { + const row = el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-material": m.코드 }, + children: [ + el("span", { className: "m02-pick__key", text: m.코드 }), + `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), + ], + }); + row.addEventListener("click", () => { + done(m); + close(); + }); + return row; + }) + : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), + ); + } catch { + if (mine === seq) showToast(TEXT.searchFailed, "error"); + } + }; + let timer = 0; + search.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void paint(), 250); + }); + body.append(search.root, list); + void paint(); + search.input.focus(); + }, + }); +} From eee8f9a459b6ebaed5001c2e2a7b3302579e2c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:21:17 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=ED=83=AD=20readableSources=20=C2=B7=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=A7=A1=EC=9D=8C=20manages=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1=20=C2=B7=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=ED=95=B4=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=ED=83=AD=20=EB=AA=A9=EB=A1=9D(39-6=20E5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Layer.ts | 72 +++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 49 +++++++++++++ ui_template/ui_template_locale_m2.ts | 2 + 3 files changed, 123 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 742f8f20a..3b94c655c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -93,6 +93,78 @@ export function accessFor( return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; } +/** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ +export function manages(company: string | null, user: SessionUser | null): boolean { + if (!company || !user) return false; + if (user.role === "SYSTEM_ADMIN") return true; + const companyAdmin = user.role === "ADMIN" || !!user.is_master; + return companyAdmin && company === ownOwner("company", user); +} + +/** 가져올 곳 탭 하나 — `people` = 다른 사람(주인은 부르는 쪽이 `fetchOwners` 로 고름) */ +export interface Source { + id: "master" | "company" | "personal" | "people" | "practice"; + layer: ManageLayer; + /** 층 주인 — 자기 층 · 마스터 · 실무문서 · 다른 사람 = null */ + owner: string | null; + label: string; +} + +/** 복사 생성 · 복사해 가져오기의 탭(39-6 · 답 2) — 읽을 수 있는 곳 + 기본 탭. + * 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터) · `practice` = 일위대가 고를 때만 */ +export function readableSources( + user: SessionUser | null, + company: string | null, + opt: { practice?: boolean } = {}, +): { tabs: Source[]; first: Source["id"] } { + const isSystem = user?.role === "SYSTEM_ADMIN"; + const own = ownOwner("personal", user); + const tabs: Source[] = [ + { id: "master", layer: "system", owner: null, label: L("M02_PickerSystem") }, + ...(company + ? [ + { + id: "company" as const, + layer: "company" as const, + owner: company === ownOwner("company", user) ? null : company, + label: L("M02_PickerCompany"), + }, + ] + : []), + ...(own + ? [ + { + id: "personal" as const, + layer: "personal" as const, + owner: null, + label: L("M02_PickerPersonal"), + }, + ] + : []), + ...(manages(company, user) + ? [ + { + id: "people" as const, + layer: "personal" as const, + owner: null, + label: L("M02_SourcePeople"), + }, + ] + : []), + ...(opt.practice && isSystem + ? [ + { + id: "practice" as const, + layer: "system" as const, + owner: null, + label: L("M02_SourcePractice"), + }, + ] + : []), + ]; + return { tabs, first: isSystem || !own ? "master" : "personal" }; +} + /** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */ export function buildNeedCompany(): HTMLElement { const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" }); diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..f731a5644 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,6 +87,55 @@ def test_주소_인자를_라우트에서_뗌(): assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_가져올_곳_탭(): + """39-6 · 답 2 — 탭 = 마스터 · 볼 회사 · 내 개인 · 다른 사람(맡은 회사) · 실무문서(시스템 관리자 · + 일위대가) · 기본 = 시스템 관리자 마스터 · 그 밖 내 개인(회사 없으면 마스터).""" + text = _text(LAYER) + parts = [ + re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M), + re.search(r"^export function manages[\s\S]*?^}", text, re.M), + re.search(r"^export interface Source[\s\S]*?^}", text, re.M), + re.search(r"^export function readableSources[\s\S]*?^}", text, re.M), + ] + assert all(parts), "ownOwner · manages · Source · readableSources 를 못 찾음" + script = ( + 'type ManageLayer = "system" | "company" | "personal";\n' + "interface SessionUser { id: string; role?: string; company_id?: number | null;" + " is_master?: boolean }\n" + "const L = (key: string): string => key;\n" + + "\n".join(p.group(0).replace("export ", "") for p in parts if p) + + "\nconst cases = JSON.parse(process.argv[1]);" + + "\nconsole.log(JSON.stringify(cases.map(([u, c, practice]) => {" + + "\n const got = readableSources(u, c, { practice });" + + "\n return [got.first, got.tabs.map((t) => t.id + (t.owner ? ':' + t.owner : ''))];" + + "\n})));" + ) + sys1 = {"id": "1", "role": "SYSTEM_ADMIN", "company_id": 1} + admin = {"id": "5", "role": "ADMIN", "company_id": 3} + master = {"id": "6", "role": "USER", "company_id": 3, "is_master": True} + user = {"id": "7", "role": "USER", "company_id": 3} + orphan = {"id": "8", "role": "USER", "company_id": None} + cases = [ + [sys1, "3", True], [sys1, "1", False], [admin, "3", True], [master, "3", False], + [user, "3", True], [orphan, None, False], + ] # fmt: skip + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(cases)], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["master", ["master", "company:3", "personal", "people", "practice"]], # 고객 회사 봄 + ["master", ["master", "company", "personal", "people"]], # 자기 회사 · 일위대가 아님 + ["personal", ["master", "company", "personal", "people"]], # 회사 관리자 · 실무문서 없음 + ["personal", ["master", "company", "personal", "people"]], # is_master = 회사 관리자 + ["personal", ["master", "company", "personal"]], # 일반 = 다른 사람 없음 + ["master", ["master"]], # 회사 없음 + ] # fmt: skip + + @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_볼_회사는_시스템_관리자만_고름(): """29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 · diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1f89b5c20 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -103,6 +103,8 @@ export const ui_locales_m2 = { M02_RootShared: ["회사 공용", "Company shared"], M02_RootReadOnly: ["읽기", "read-only"], M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], + M02_SourcePeople: ["다른 사람", "Others"], + M02_SourcePractice: ["실무문서", "Practice documents"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", From ab84c763b79f65f093efcc684ab445cb5adb0b36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:22:24 +0900 Subject: [PATCH 04/16] =?UTF-8?q?refactor(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=ED=95=A9=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EB=91=98=EC=A7=B8=20=EC=B8=B5=20=EB=8B=A8=EC=B6=94=20=EB=B2=8C?= =?UTF-8?q?=20=EC=A7=80=EC=9B=80(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor.ts · Labor_Tree.ts 지움 · 페이지 · 좌측 이음 · 해시 표시(mark) 지움 - 나무 부품 일위대가 전용 칸(clipKind · maxDepth · item) 지움 - 옛 나무 시험을 새 짜임 검사로 갈음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Labor.ts | 222 ------------------ .../M02_MasterTemplete_Labor_Tree.ts | 45 ---- .../M02_MasterTemplete_TreePanel.ts | 23 +- .../M02_MasterTemplete_UI_Page.ts | 36 +-- .../M02_MasterTemplete_UI_Side.ts | 24 +- .../M02_MasterTemplete_UI_Style.css | 8 - resources/tester/test_m02_labor_tree.py | 131 ++--------- resources/tester/test_m02_layer_screen.py | 13 +- 8 files changed, 39 insertions(+), 463 deletions(-) delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts deleted file mode 100644 index 985d1a1f8..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor.ts - * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추 - * (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` · - * 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`). - * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). - * ========================================================================== */ - -import { el, showConfirmDialog } from "@ui/ui_template_elements"; -import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; -import { - editComboTree, - editComboTreeMany, - fetchComboList, - pasteCombos, - type ComboList, - type ComboTreeEdit, - type LaborScope, -} from "./M02_MasterTemplete_Labor_Api"; -import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; -import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; - -/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ -function treeError(error: unknown): Error { - if (!(error instanceof ApiError)) - return error instanceof Error ? error : new Error(String(error)); - const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; - const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; - return error.status === 409 ? new StaleError(text) : new Error(text); -} - -export interface LaborLibraryOptions { - scope: LaborScope; - canEdit: boolean; - /** 「〇〇로 복사」 — 이 사람이 쓸 수 있는 층(지금 보는 층 뺌 · 페이지가 구조물 나무와 같은 목록을 줌) */ - copyTo?: { layer: LaborScope["layer"]; owner: string | null; label: string }[]; - /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ - reveal: () => Promise; -} - -export interface LaborLibrary { - /** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */ - root: HTMLElement; - /** 좌측 패널 나무 */ - tree: HTMLElement; - /** 나무 맨 위 더하기 「+」(층 단추 옆) */ - add: HTMLElement; - /** 켜진 층 단추를 다시 누름 — 조합 목록으로(저장 안 한 고침은 물음 · PLAN 30-4) */ - back: () => Promise; - refresh: () => Promise; - /** 제목 줄 오른쪽 끝 도구 — 「새 조합」(읽기 층 = null · PLAN 30-5) */ - tools: HTMLElement | null; - /** 메인 칸 「저장 안 한 고침」 손잡이 */ - bind: (setDirty: (dirty: boolean) => void) => void; -} - -export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { - const { scope } = opt; - let setDirty: (dirty: boolean) => void = () => {}; - let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 - const took = (list: ComboList): ComboList => { - keys = new Set(list.줄.map((c) => c.키)); - editor.setList(list.줄); - return list; - }; - const editor = createLaborEditor({ - scope, - canEdit: opt.canEdit, - onDirty: (dirty) => setDirty(dirty), - onSaved: async (key) => { - await tree.refresh(); - tree.select(key); - }, - }); - const tree = createTreePanel({ - layer: scope.layer, - owner: scope.owner, - canEdit: opt.canEdit, - maxDepth: 2, - item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, - api: { - fetch: async () => { - return toView(took(await fetchComboList(scope))); - }, - edit: async (_layer, edit, 판) => { - const body = { ...edit, 판 } as ComboTreeEdit; - const got = await editComboTree(body, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), noteOf(edit, got.지운조합)); - }, - // 여러 개 옮기기 · 지우기(PLAN 30-3) — 고른 것마다 나무 고침 하나 · 서버가 한 번에 저장 - many: async (_layer, body, 판) => { - const ops = body.대상.map((ref) => ({ - op: body.op, - ...(ref.키 ? { 키: ref.키 } : { 경로: ref.경로 }), - ...(body.op === "move" ? { 새경로: body.새경로 ?? [] } : {}), - })); - const got = await editComboTreeMany(ops, 판, scope).catch((error: unknown) => { - throw treeError(error); - }); - return toView(took(got), { op: body.op, 지운항목: got.지운조합 }); - }, - // 붙여넣기 · 「〇〇로 복사」 — 대상 층(`layer` · `owner`)에 새 키 · 이 나무가 아니면 이 나무는 그대로 - paste: async (layer, owner, clip, dest) => { - const to = { layer, owner } as LaborScope; - const pick = { - 키: clip.refs.flatMap((ref) => (ref.키 ? [ref.키] : [])), - 폴더: clip.refs.flatMap((ref) => (ref.키 ? [] : [ref.경로])), - }; - const got = await fetchComboList(to) - .then((list) => pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)) - .catch((error: unknown) => { - throw treeError(error); - }); - const here = layer === scope.layer && (owner ?? null) === (scope.owner ?? null); - return toView(took(here ? got : await fetchComboList(scope))); - }, - }, - clipKind: "일위대가", - copyTo: opt.copyTo, - onPick: async (item) => { - await opt.reveal(); - tree.select(item.key); - void editor.open(item.key); - }, - // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) - settle: async () => { - if (!editor.isDirty()) return "clean"; - if (!(await showConfirmDialog(tc("BackAsk")))) return false; - editor.showList(); - return "changed"; - }, - // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 - onChanged: async () => { - const open = editor.current(); - if (!open) return; - if (keys.has(open)) await editor.open(open); - else editor.showList(); - }, - }); - // 찾기 칸 하나(PLAN 29-6) — 나무와 조합 목록을 같은 글 · 같은 규칙으로 거름 - tree.search - .querySelector("input") - ?.addEventListener("input", (event) => - editor.setQuery((event.target as HTMLInputElement).value), - ); - return { - root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }), - tree: tree.root, - add: tree.add, - back: async () => { - await opt.reveal(); // 일위대가 화면이 아니면 먼저 띄움 - if (editor.current() === null) return; - if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return; - editor.showList(); - }, - refresh: async () => { - const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6) - try { - await tree.refresh(); - } finally { - stop(); - } - }, - tools: editor.newButton, - bind: (fn) => (setDirty = fn), - }; -} - -export interface LaborSectionOptions { - /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ - show: (label: string, node: HTMLElement, tools?: HTMLElement | null) => (dirty: boolean) => void; - /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ - confirmLeave: () => Promise; - /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ - onOpen: () => void; - /** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */ - make: (reveal: () => Promise) => LaborLibrary; - /** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */ - bar?: (add: HTMLElement, back: () => void) => HTMLElement; - /** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */ - wrap?: (tree: HTMLElement) => HTMLElement; -} - -export interface LaborSection { - title: string; - body: HTMLElement; - open: () => Promise; - /** 다른 화면을 열었음 — 고름 표시만 지움 */ - clear: () => void; -} - -/** 컨테이너 몸 = 층 단추 + 나무(같은 이름 줄 단추 없음 · 컨테이너 제목을 펼치면 엶 · PLAN 29-6) */ -export function buildLaborSection(opt: LaborSectionOptions): LaborSection { - let opened = false; - const mark = (on: boolean): void => { - opened = on; - }; - - async function open(): Promise { - if (opened || !(await opt.confirmLeave())) return; - library.bind(opt.show(tc("Library"), library.root, library.tools)); - opt.onOpen(); - mark(true); - await library.refresh(); - } - // 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음 - const library = opt.make(open); - void library.refresh(); - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - children: [ - ...(opt.bar ? [opt.bar(library.add, () => void library.back())] : []), - opt.wrap ? opt.wrap(library.tree) : library.tree, - ], - }); - return { title: tc("Title"), body, open, clear: () => mark(false) }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts deleted file mode 100644 index d18017cae..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Labor_Tree.ts - * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. - * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). - * ========================================================================== */ - -import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; -import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; - -/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ -export const pathOf = (c: ComboSummary): string[] => - c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; - -/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ -export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ - 판: list.판, - 폴더: list.폴더, - items: list.줄.map((c) => ({ - key: c.키, - 열: c.키, - 경로: [...pathOf(c), c.이름], - 이름: c.이름, - 단위: c.단위, - })), - ...(note ? { 알림: note } : {}), -}); - -/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ -export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { - const at = edit.경로 ?? []; - const last = at[at.length - 1] ?? ""; - if (edit.키 && edit.op !== "delete") { - const where = edit.op === "move" ? (edit.새경로 ?? []) : at; - return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; - } - const path = - edit.op === "add-folder" - ? [...at, edit.이름 ?? ""] - : edit.op === "rename" - ? [...at.slice(0, -1), edit.이름 ?? ""] - : edit.op === "move" - ? [...(edit.새경로 ?? []), last] - : undefined; - return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 426b52b8d..014e9c9b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -81,18 +81,12 @@ export interface TreePanelOptions { dest: string[], ) => Promise; }; - /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ - 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 }[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; - /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ - maxDepth?: number; - /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ - item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -123,7 +117,7 @@ const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const owner = opt.owner ?? null; const api = opt.api ?? { fetch: fetchTree, edit: editTree, many: editTreeMany, paste: pasteTree }; - const clipKind = opt.clipKind ?? "구조물"; + const clipKind = "구조물"; // 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임 let view: TreeView | null = null; let text = ""; let activeCol: string | null = null; @@ -213,13 +207,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth - ? [] - : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), - { - label: opt.item?.add ?? L("M02_TreeAddItem"), - run: () => void addItem(target.path), - }, + { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, + { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, ] : []), ...(isTop @@ -466,7 +455,6 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; - if (opt.item?.onAdd) return opt.item.onAdd(parent); // 종류(맨 위 폴더 · 고른 폴더면 그 안) + 이름 + 일위대가 · 단위 안 받음(PLAN 31-2) const kinds = view ? buildFolders(view).folders.map((f) => f.path[0]) : []; const got = await askNewItem({ @@ -490,10 +478,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: - target.kind === "item" - ? (opt.item?.name ?? L("M02_TreeItemName")) - : L("M02_TreeFolderName"), + label: target.kind === "item" ? L("M02_TreeItemName") : L("M02_TreeFolderName"), value: nameOf(target), }, ]); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 2be94a20d..a19bc482a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -27,7 +27,6 @@ import { type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; -import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { headRenameEdit, pickCombo, @@ -86,14 +85,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - // 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 · - // 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로) - let view: OpenTarget = entry.open; - const mark = (open: OpenTarget): void => { - view = open; - const query = new URLSearchParams(entryQuery({ ...entry, open })); - history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`); - }; const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; @@ -102,9 +93,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => createLayerBar({ user, @@ -127,11 +118,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void Promise.all([side.refresh(), tree.refresh()]), - () => { - side.paint(); - // 일위대가 화면에서 양식으로 옮김(일위대가 · 옵션 화면은 current 없음) - if (main.current() && view === "일위대가") mark("구조물조합"); - }, + () => side.paint(), { top: readOnlyBar?.root, search: tree.search, @@ -168,19 +155,6 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], - labor: ({ onOpen }) => - buildLaborSection({ - show: main.showView, - confirmLeave: main.confirmLeave, - onOpen: () => { - onOpen(); - mark("일위대가"); - }, - bar: (add, back) => layerBar("일위대가", add, back), - wrap: (panel) => peopleTree("일위대가", panel), - make: (reveal) => - buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal, copyTo }), - }), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -200,8 +174,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; - /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ - labor?: (hooks: { onOpen: () => void }) => LaborSection; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; } @@ -85,7 +82,7 @@ export interface SideHandle { refresh: () => Promise; /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; - /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ + /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ reveal: (target: "옵션" | "도면" | "일위대가") => void; } @@ -136,7 +133,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), labor?.clear(), paint()), + onOpen: () => (options.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -148,9 +145,8 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), labor?.clear(), paint()), + onOpen: () => (matset.clear(), paint()), }); - const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { @@ -181,15 +177,9 @@ export function buildSide(opt: SideOptions): SideHandle { : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [ - ...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())] : []), - summary, - ...(extras ? [extras.옵션, extras.도면] : []), - ], + children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], }); 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] }); @@ -263,7 +253,6 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); - if (opt.getOpen()) labor?.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -388,10 +377,7 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") { - labor?.body.closest("section")?.classList.remove("is-collapsed"); - return void labor?.open(); - } + if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..c4c51d1ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -222,14 +222,6 @@ font-size: 11px; } -/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */ -.m02-labor { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - .m02-main__head + .m02-tree__search { margin-bottom: var(--spacing-8); } diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 9268309c8..755dac40b 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -1,124 +1,35 @@ -"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. +"""좌측 패널 일위대가(PLAN 39-2) — 맨 위 「일위대가 조합」 컨테이너 · 조합 나무 · 둘째 층 단추 벌 지움 · +일위대가는 구조물 조합 하위(구조물 도면 → 일위대가 → 면적산출). 글 검사(화면은 ORCA). -못박는 것 - - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. - - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · - 지우기는 고름 없음. +옛 일위대가 라이브러리 나무 시험(PLAN 27-2b · 30-3 · 30-5)은 나무를 지우며 이 검사로 갈음. """ -import json -import shutil -import subprocess from pathlib import Path -import pytest - ROOT = Path(__file__).resolve().parents[2] -SCRIPT = r""" -const fs = require("fs"); -const ts = require(process.argv[1] + "/config/node_modules/typescript"); -const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; -const src = fs.readFileSync(process.argv[1] + file, "utf8"); -const js = ts.transpileModule(src, { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const box = { exports: {} }; -new Function("exports", "module", "require", js)(box.exports, box, require); -const m = box.exports; -const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); -const list = { - 판: "v1", - 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], - 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), - row("UP1-000003", "다", null, "무시")], -}; -const view = m.toView(list); -const notes = [ - { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, - { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, - { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, - { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, - { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, - { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, - { op: "delete", 경로: ["빈"] }, -].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); -console.log(JSON.stringify({ view, notes })); -""" +M02 = ROOT / "M02_MasterTemplete" -@pytest.fixture(scope="module") -def got(): - if shutil.which("node") is None: - pytest.skip("node 가 없음") - proc = subprocess.run( - ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" - ) - assert proc.returncode == 0, proc.stderr - return json.loads(proc.stdout) +def _text(name: str) -> str: + return (M02 / name).read_text("utf-8") -def test_조합이_나무_항목으로(got): - items = got["view"]["items"] - assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] - assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] - assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] - assert "알림" not in got["view"] +def test_일위대가_나무_파일과_이음이_없음(): + assert not (M02 / "M02_MasterTemplete_Labor.ts").exists() + assert not (M02 / "M02_MasterTemplete_Labor_Tree.ts").exists() + page = _text("M02_MasterTemplete_UI_Page.ts") + assert "buildLaborSection" not in page and 'layerBar("일위대가"' not in page + side = _text("M02_MasterTemplete_UI_Side.ts") + assert "LaborSection" not in side and "labor?." not in side -def test_나무_고침_알림(got): - add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] - assert add["경로"] == ["석축", "메쌓기"] and "열" not in add - assert rename["경로"] == ["석축", "찰"] - assert move["경로"] == ["찰쌓기"] - assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] - assert rename_item["경로"] == ["석축", "나나"] - assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] - assert "경로" not in delete_folder +def test_나무_부품에_일위대가_전용_칸_없음(): + panel = _text("M02_MasterTemplete_TreePanel.ts") + for gone in ("maxDepth", "opt.item", "opt.clipKind", "clipKind?:"): + assert gone not in panel, gone -def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): - """브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) · - 일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시 - `open=일위대가`(다시 그리지 않고 주소만).""" - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 - assert ( - 'bar: (add, back) => layerBar("일위대가", add, back),' in page - and 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - ) - labor = page.split("buildLaborSection({")[1].split("make:")[0] - assert 'mark("일위대가")' in labor # 여는 순간 해시 open - assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) - assert 'if (main.current() && view === "일위대가") mark("구조물조합")' in page - - -def test_일위대가_나무에_여러_개_복사_붙여넣기_연결(): - """PLAN 30-3 — 공용 나무 부품(30-2) 콜백을 일위대가 서버 길에 이음 · - 붙임 종류는 따로(구조물과 안 섞임).""" - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'clipKind: "일위대가"' in labor and "copyTo: opt.copyTo" in labor - assert "editComboTreeMany(ops, 판, scope)" in labor # 여러 개 옮기기 · 지우기 - assert "pasteCombos(clip.from as LaborScope, pick, dest, list.판, to)" in labor # 대상 층 - page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert "canEdit: isAdmin, reveal, copyTo })" in page # 구조물 나무와 같은 「〇〇로 복사」 목록 - - -def test_새_조합_단추는_제목_줄_오른쪽_목록은_공용_표(): - """PLAN 30-5 — 「새 조합」 = 제목 줄 오른쪽 끝(읽기 층 없음) · 목록 = 공용 표(줄 선). - 머리 줄도 공용 표 모양.""" - edit = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts").read_text("utf-8") - assert ( - "newButton: edit" in edit and 'className: "ui-general-block__table m02-labor__list"' in edit - ) - assert ( - 'tc("New"), variant: "filled", onClick: () => startNew([]) })]' not in edit - ) # 목록 안 옛 자리 - labor = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor.ts").read_text("utf-8") - assert 'library.bind(opt.show(tc("Library"), library.root, library.tools))' in labor - main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - assert ( - 'slot.replaceChildren(el("span", { className: "m02-main__tools", children: [tools] }))' - in main - ) - css = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Style.css").read_text("utf-8") - assert ".m02-main__tools {\n display: inline-flex;\n margin-left: auto;" in css +def test_층_단추는_구조물_조합_한_벌(): + page = _text("M02_MasterTemplete_UI_Page.ts") + assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 + assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index c7bb3f17f..299ec5e4b 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -136,7 +136,7 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page - assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page + assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움 panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( "api.fetch(opt.layer, owner)" in panel and "api.edit(opt.layer, body_, 판, owner)" in panel @@ -219,17 +219,13 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): - """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · - 저장 안 한 고침은 물음) · 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · - 끌어다 옮기기는 쓸 수 있는 층만.""" + """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · + 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert 'bar: (add, back) => layerBar("일위대가", add, back)' in page assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page - 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) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") @@ -259,10 +255,9 @@ def test_구조물_조합_제목_줄_새_항목(): def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): - """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 + """31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" 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] From 8739148bba03d03b7bbc8287f5fc9fc28fa67cab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:01 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(spreadsheet):=20=EC=86=90=EC=9E=A1?= =?UTF-8?q?=EC=9D=B4=20setLink=20=E2=80=94=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EC=B9=B8=EC=97=90=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=90=A8=20(PLAN=2039-4=20D4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 13 +++ .../spreadsheet/spreadsheet_link_set.ts | 55 +++++++++++ .../spreadsheet/spreadsheet_link_set_entry.ts | 92 +++++++++++++++++++ resources/tester/test_spreadsheet_set_link.py | 68 ++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_set.ts create mode 100644 resources/tester/spreadsheet/spreadsheet_link_set_entry.ts create mode 100644 resources/tester/test_spreadsheet_set_link.py diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 87ec2cc7b..d452f6b95 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -24,6 +24,7 @@ import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; import type { ConditionOptions } from "./spreadsheet_condition"; import type { LinkOptions, Links } from "./spreadsheet_links"; +import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; @@ -48,6 +49,7 @@ import type { export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types"; export type { LinkItem, LinkOptions } from "./spreadsheet_links"; +export type { LinkInfo } from "./spreadsheet_link_set"; export interface SpreadsheetOptions { /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ @@ -76,6 +78,9 @@ export interface SpreadsheetHandle { recalc(): CalcValues; /** 알약 키 → 연결 칸 값(빈 칸 0) */ getLinkedValues(): Record; + /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · + * 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo): boolean; destroy(): void; } @@ -288,6 +293,14 @@ export function createSpreadsheet( } return out; }, + setLink(key, cell, info) { + if (readOnly) return false; + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + if (command === false) return false; + if (command) dispatch(command); + loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts new file mode 100644 index 000000000..8f740992c --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * spreadsheet_link_set.ts (주인 D) + * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · + * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * ========================================================================== */ + +import { parseA1, toA1 } from "./spreadsheet_address"; +import type { CellLink, Command, LinkMaterial, Workbook } from "./spreadsheet_types"; + +/** 연결에 같이 적을 알약 정보 — 없으면 있던 연결 것을 그대로 */ +export interface LinkInfo { + 이름?: string; + 단위?: string; + 자재?: LinkMaterial; +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +export function linkCommand( + book: Workbook, + sheetId: string, + key: string, + cell: string | null, + info: LinkInfo = {}, +): Command | null | false { + const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); + if (cell !== null && !at) return false; + const a1 = at ? toA1(at.r, at.c) : null; + const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); + const home = book.시트.find((s) => s.id === sheetId); + if (a1 && !home) return false; + if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; + if (one) { + const 이름 = info.이름 ?? old?.이름; + const 단위 = info.단위 ?? old?.단위; + const 자재 = info.자재 ?? old?.자재; + if (이름) one.이름 = 이름; + if (단위) one.단위 = 단위; + if (자재) one.자재 = 자재; + } + const commands: Command[] = []; + for (const s of book.시트) { + const was = s.연결 ?? []; + const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const next = s === home && one ? [...rest, one] : rest; + if (next.length === was.length && next.every((l, i) => l === was[i])) continue; + const copy = structuredClone(s); + if (next.length) copy.연결 = next; + else delete copy.연결; + commands.push({ 종류: "시트통째", 시트: copy }); + } + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts new file mode 100644 index 000000000..ce15d5b73 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -0,0 +1,92 @@ +/* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. + * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; +import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +let pass = 0; +const failures: string[] = []; +const eq = (name: string, got: unknown, want: unknown): void => { + if (JSON.stringify(got) === JSON.stringify(want)) pass += 1; + else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`); +}; + +const book = (): Workbook => + ({ + 종류: "통합문서", + 판: 1, + 열: "t", + 서식: [{}], + 시트: [ + { + id: "s1", + 이름: "면적산출", + 칸: { B1: { 값: 12.5 } }, + 연결: [{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }], + }, + { id: "s2", 이름: "둘", 칸: {}, 연결: [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }] }, + ], + 활성: "s1", + }) as unknown as Workbook; +const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); +const run = (b: Workbook, c: Command | null | false) => { + if (!c) return null; + return applyCommand(b, c).undo; +}; + +// 새 키를 칸에 — 이름 · 단위 · 자재가 같이 들어감 · 소문자 · 빈칸 주소도 받음 +let b = book(); +const mt = { 코드: "MT000008", 이름: "치즐", 규격: "", 단위: "EA" }; +let undo = run( + b, + linkCommand(b, "s1", "자재|MT000008", " d2 ", { 이름: "치즐", 단위: "EA", 자재: mt }), +); +eq("새 연결", b.시트[0].연결?.[1], { + 키: "자재|MT000008", + 칸: "D2", + 이름: "치즐", + 단위: "EA", + 자재: mt, +}); +// 되돌리기 = 처음 그대로 +run(b, undo); +eq("되돌림", links(b), links(book())); + +// 있는 키를 다른 칸으로 — 옛 칸 빠지고 이름 · 단위는 있던 것 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); +eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); + +// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); +eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); + +// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 +b = book(); +undo = run(b, linkCommand(b, "s1", "치기|㎥", "A1")); +eq("시트 넘어 옮김", links(b), [ + [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "치기|㎥", 칸: "A1", 이름: "치기" }, + ], + null, +]); +run(b, undo); +eq("시트 넘어 되돌림", links(b), links(book())); + +// 풀기 — 그 키만 빠지고 마지막이면 `연결` 칸이 없어짐 +b = book(); +run(b, linkCommand(b, "s1", "깔기|㎡", null)); +eq("풀기", links(b), [null, [{ 키: "치기|㎥", 이름: "치기", 칸: "C3" }]]); + +// 바뀔 것 없음 = null · 틀린 주소 = false · 없는 시트 = false +b = book(); +eq("이미 그 칸", linkCommand(b, "s1", "깔기|㎡", "b6"), null); +eq("없는 키 풀기", linkCommand(b, "s1", "없음", null), null); +eq("틀린 주소", linkCommand(b, "s1", "깔기|㎡", "가1"), false); +eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); +eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); +eq("문서 그대로", links(b), links(book())); + +console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py new file mode 100644 index 000000000..13e3ba179 --- /dev/null +++ b/resources/tester/test_spreadsheet_set_link.py @@ -0,0 +1,68 @@ +"""스프레드시트 손잡이 `setLink` — 알약 키를 칸에 잇기 · 풀기(PLAN 39-4 D4). + +순수 길(`spreadsheet_link_set.ts` · DOM 모름)을 vite `--ssr` 로 번들해 Node 로 돌림 — +새 연결 · 옮김 · 칸 하나 = 키 하나 · 시트 넘어 옮김 · 풀기 · 되돌리기 · 바뀔 것 없음 · 틀린 주소. +손잡이 몸(`spreadsheet.ts`)은 글로 확인 — 화면 조작은 ORCA. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_link_set_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_setLink_순수_길(): + with tempfile.TemporaryDirectory(prefix="spreadsheet_link_set_") as outdir: + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + entry_rel, + "--outDir", + outdir, + ], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "spreadsheet_link_set_entry.js")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), run.stderr + result = json.loads(run.stdout) + assert result["fail"] == 0, result["failures"] + assert result["pass"] == 13 + + +def test_손잡이_setLink_몸(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src + body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert "if (readOnly) return false;" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) + assert "loadLinks();" in body From 0b158636ccb6d8233aac4a914db2ae472e91f254 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:23:56 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20AreaLink=20=E2=80=94=20=EC=A7=80=EA=B8=88=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20=C2=B7=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=A0=95=EB=B3=B8=EC=9D=80=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EB=AC=B8=EC=84=9C=20=EC=97=B0=EA=B2=B0(setLink)=20?= =?UTF-8?q?(PLAN=2039-4=20D5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_AreaLink.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts new file mode 100644 index 000000000..9bd76f293 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaLink.ts + * 면적산출 상세 — 칸 지정 모달(PLAN 39-4 D5). 오른쪽 패널의 담은 로직 · 자재 줄을 누르면 엶. + * 그 줄 알약 키(로직 물량 「이름|단위」 · 자재 「자재|MT키」)를 면적산출 칸에 잇기 · 옮기기 · 풀기. + * 정본 = 면적산출 문서 `연결` 한 곳 — 스프레드시트 손잡이 `setLink`(알약 누름과 같은 규칙 · 되돌리기 됨)라 + * 아래 알약 줄 · 칸 세모 · 패널 수량이 같이 따라감. 손 입력 수량은 일위대가 몫(여기 없음) · 저장은 [저장]. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; + +const TEXT = { + title: "칸 지정 — {value}", + close: "닫기", + now: "지금 연결", + none: "연결 없음", + picked: "지금 고른 칸으로 ({value})", + address: "칸 주소", + hint: "예 B6", + link: "잇기", + unlink: "풀기", + bad: "칸 주소가 틀림 — 예 B6", + readOnly: "읽기만 — 고칠 수 없음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaLinkOptions { + /** 줄 이름(모달 제목) */ + label: string; + /** 알약 키 */ + key: string; + /** 연결에 같이 적을 이름 · 단위 · 자재 */ + info: LinkInfo; + sheet: SpreadsheetHandle; + /** 시트에서 지금 고른 칸(A1) */ + picked: () => string; + readOnly: boolean; +} + +/** 지금 연결 칸 · 값 글 — 「B6 = 12.5 ㎡」 · 없으면 null */ +function linkedText(opts: AreaLinkOptions): string | null { + const link = opts.sheet + .getDoc() + .시트.flatMap((s) => s.연결 ?? []) + .find((l) => l.키 === opts.key); + if (!link) return null; + const value = opts.sheet.getLinkedValues()[opts.key]; + return [link.칸, "=", String(value ?? ""), opts.info.단위 ?? ""].join(" ").trim(); +} + +export function openAreaLink(opts: AreaLinkOptions): void { + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-area-link", + mount: (body) => { + const now = el("p", { className: "m02-area-link__now" }); + const paint = (): void => { + const text = linkedText(opts); + now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; + unlink.disabled = opts.readOnly || !text; + }; + const done = (cell: string | null): boolean => { + if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + close(); + return true; + }; + const here = opts.picked(); + const pick = createButton({ + label: fill(TEXT.picked, here), + variant: "filled", + size: "sm", + onClick: () => void done(here), + }); + const address = createInputField({ label: TEXT.address, placeholder: TEXT.hint }); + const link = createButton({ + label: TEXT.link, + variant: "ghost", + size: "sm", + onClick: () => { + if (!done(address.input.value)) address.setError(TEXT.bad); + }, + }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => void done(null), + }); + address.input.addEventListener("input", () => address.setError(undefined)); + address.input.addEventListener("keydown", (ev) => ev.key === "Enter" && link.click()); + pick.disabled = link.disabled = address.input.disabled = opts.readOnly; + paint(); + body.append( + now, + ...(opts.readOnly + ? [el("p", { className: "m02-struct__empty", text: TEXT.readOnly })] + : []), + el("div", { className: "m02-area-link__row", children: [pick, unlink] }), + el("div", { className: "m02-area-link__row", children: [address.root, link] }), + ); + if (!opts.readOnly) address.input.focus(); + }, + }); +} From 18a063925351ff1e5b72deddf1b7ecaf3522859c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:24:00 +0900 Subject: [PATCH 07/16] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=20=EB=AA=A9=EB=A1=9D=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=E2=80=94=20M01=20=EC=B0=BE=EA=B8=B0=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=AA=BD=20=C2=B7=20=ED=91=9C=20=EA=BC=B4=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=EB=A7=88=EB=8B=A4=20=EC=B9=B8(=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=ED=86=B5=EC=A7=B8)=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B9=B8=20=EC=88=98=EC=A0=95=EC=9D=BC?= =?UTF-8?q?=20=C2=B7=20labor(=EB=B2=88=ED=98=B8=20=C2=B7=20=EB=8B=B4?= =?UTF-8?q?=EC=9D=80=20=EB=A1=9C=EC=A7=81=20=EC=88=98=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H1 · H6 — 전체 항목 · 복사 생성 · 항목 하나 복사가 같이 쓸 목록 하나. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 182 ++++++++++++++++++ .../M02_MasterTemplete_Items.py | 40 +++- resources/tester/helper_m02_item_list.cjs | 65 +++++++ resources/tester/test_m02_item_list.py | 94 +++++++++ resources/tester/test_m02_layers_store.py | 8 +- 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemList.ts create mode 100644 resources/tester/helper_m02_item_list.cjs create mode 100644 resources/tester/test_m02_item_list.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts new file mode 100644 index 000000000..363c48e5c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemList.ts + * 구조물 항목 목록 하나(PLAN 39-5) — 「전체 항목」(도면 · 일위대가 · 면적산출) · 복사 생성 · 항목 하나 + * 복사가 같이 씀. 꼴 = M01 마스터 화면 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표 + * (`m01-master__grid`) 를 M01 파일에서 그대로 import(옮기지 않음). + * 자료 = 층 나무 항목(`GET /layers/{층}/tree` — 서버 `items` 의 `수정일` · `labor` 칸) · 칸은 종류마다. + * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). + * ========================================================================== */ + +import { createInputField, el } from "@ui/ui_template_elements"; +import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; +import "../M01_MasterData/M01_MasterData_UI_Style.css"; +import { fetchTree, type Layer, type TreeItem } from "./M02_MasterTemplete_Api_Fetch"; +import { folderOf, leafName, matchText } from "./M02_MasterTemplete_TreePanel_Model"; + +/** 목록 종류 — 하위 컨테이너 셋 + 항목 통째(복사 생성) */ +export type ListKind = "structure" | "area" | "labor" | "item"; + +/** 나무 항목 줄 + 목록 칸(서버 `M02_MasterTemplete_Items._list_extras`) */ +export interface ItemRow extends TreeItem { + 도번?: string | null; + 면적번?: string | null; + 수정일?: Partial>; + labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; +} + +export interface Col { + head: string; + get: (row: R) => string; +} + +const TEXT = { + search: "찾기", + none: "항목이 없음", + noMatch: "맞는 항목이 없음", + loading: "불러오는 중", +}; + +const SIZE = 30; +const DASH = "—"; +const day = (iso?: string): string => (iso ? iso.slice(0, 10) : ""); +const money = (value: number | null | undefined): string => + typeof value === "number" ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) : DASH; +/** 가장 늦게 고친 날 — 항목 통째 */ +const latest = (row: ItemRow): string => + day( + Object.values(row.수정일 ?? {}) + .sort() + .pop(), + ); + +const NAME: Col = { head: "이름", get: (r) => leafName(r) }; +const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; +const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; +const changed = (part: "structure" | "area" | "labor"): Col => ({ + head: "고친 날", + get: (r) => day(r.수정일?.[part]), +}); + +/** 종류마다 칸(39-5 · 정함 5장) — 규격 · 층 칸 없음(한 목록 = 한 층) */ +export const ITEM_COLS: Record[]> = { + structure: [ + { head: "도번", get: (r) => r.도번 || DASH }, + NAME, + FOLDER, + UNIT, + changed("structure"), + ], + area: [{ head: "면적번", get: (r) => r.면적번 || DASH }, NAME, FOLDER, UNIT, changed("area")], + labor: [ + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + NAME, + FOLDER, + UNIT, + { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "합계", get: (r) => money(r.labor?.합계) }, + changed("labor"), + ], + item: [ + NAME, + FOLDER, + UNIT, + { head: "도번", get: (r) => r.도번 || DASH }, + { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, + { head: "면적번", get: (r) => r.면적번 || DASH }, + { head: "고친 날", get: latest }, + ], +}; + +/** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ +export function pageOf( + rows: R[], + cols: Col[], + query: string, + page: number, + size = SIZE, +): { shown: R[]; page: number; total: number } { + const hits = rows.filter((r) => + matchText( + cols.map((c) => c.get(r)), + query, + ), + ); + const last = Math.max(1, Math.ceil(hits.length / size)); + const at = Math.min(Math.max(1, page), last); + return { shown: hits.slice((at - 1) * size, at * size), page: at, total: hits.length }; +} + +/** 한 층 항목 — `owner` = 남의 층(없으면 자기 층) */ +export const loadItems = async (layer: Layer, owner: string | null): Promise => + (await fetchTree(layer, owner)).items as ItemRow[]; + +export interface ItemListHandle { + root: HTMLElement; + /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ + show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ + message: (text?: string) => void; +} + +export function buildItemList(): ItemListHandle { + let draw = (): void => {}; + let page = 1; + const searchField = createInputField({ type: "search", placeholder: TEXT.search }); + const search = searchField.input; + const grid = el("div", { className: "m01-master__grid-wrap" }); + let pager = buildPager(0, SIZE, 1, () => {}); + const note = (text: string): void => { + grid.replaceChildren(el("p", { className: "m01-master__empty", text })); + }; + + const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + page = 1; + draw = (): void => { + const got = pageOf(rows, cols, search.value, page); + page = got.page; + const next = buildPager(got.total, SIZE, page, (p) => { + page = p; + draw(); + }); + pager.replaceWith(next); + pager = next; + if (!got.shown.length) return note(rows.length ? TEXT.noMatch : TEXT.none); + const body = got.shown.map((row) => { + const tr = el("tr", { + className: "m01-master__table-row", + children: cols.map((c) => { + const text = c.get(row); + return el("td", { text, attrs: { title: text } }); + }), + }); + tr.addEventListener("click", () => onPick(row)); + return tr; + }); + grid.replaceChildren( + el("table", { + className: "m01-master__grid", + children: [ + el("thead", { + children: [el("tr", { children: cols.map((c) => el("th", { text: c.head })) })], + }), + el("tbody", { children: body }), + ], + }), + ); + }; + draw(); + }; + + search.addEventListener("input", () => { + page = 1; + draw(); + }); + const root = el("div", { + className: "m02-itemlist", + children: [buildHeadFilters([searchField.root, pager]), grid], + }); + // 처음 = 빈 표 자리(줄이 오면 `show`) + note(TEXT.loading); + return { root, show, message: (text = TEXT.loading) => note(text) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 90f0dacd7..385c46d28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path from typing import Any @@ -85,10 +86,47 @@ def items(folder: Path) -> list[dict[str, Any]]: k: (head or r.get("열")) if r.get(k) else None for k in ("도번", "면적번", "재료번") }, } - out.append({"key": r.get("키"), **{k: r.get(k) for k in ITEM_FIELDS}, "names": names}) + out.append( + { + "key": r.get("키"), + **{k: r.get(k) for k in ITEM_FIELDS}, + "names": names, + **_list_extras(Path(folder), str(r.get("열"))), + } + ) return out +def _list_extras(folder: Path, col: str) -> dict[str, Any]: + """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + stamps: dict[str, str] = {} + for kind in ("structure", "area", "labor"): + path = folder / kind / f"{col}.json" + if path.is_file(): + stamps[kind] = datetime.fromtimestamp(path.stat().st_mtime).isoformat( + timespec="seconds" + ) + labor = None + path = folder / "labor" / f"{col}.json" + if path.is_file(): + try: + doc = json.loads(path.read_bytes()) + except ValueError: + doc = {} + total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") + try: + total = None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + total = None # 수 아닌 값(옛 꼴)은 「—」 + labor = { + "번호": doc.get("일위대가번"), + "로직수": len(doc.get("담은로직") or []), + "합계": total, + } + return {"수정일": stamps, "labor": labor} + + def _fresh_id(want: str, used: set[str]) -> str: out, n = want, 2 while out in used: diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs new file mode 100644 index 000000000..875074b62 --- /dev/null +++ b/resources/tester/helper_m02_item_list.cjs @@ -0,0 +1,65 @@ +/* M02 항목 목록(`M02_MasterTemplete_ItemList.ts`) 칸 · 찾기 · 쪽 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 + * JSON 으로 냄. 화면 부품(ui_template · M01 머리 · css)은 빈 것으로 갈음 · 나무 자료(`TreePanel_Model`)는 진짜. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const M02 = path.join(__dirname, "..", "..", "M02_MasterTemplete"); +const load = (name, stubs) => { + const js = ts.transpileModule(fs.readFileSync(path.join(M02, name), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const need = (id) => (id in stubs ? stubs[id] : {}); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +}; +const model = load("M02_MasterTemplete_TreePanel_Model.ts", {}); +const m = load("M02_MasterTemplete_ItemList.ts", { + "./M02_MasterTemplete_TreePanel_Model": model, +}); + +const row = (열, 경로, extra = {}) => ({ + key: `SS-${열}`, + 열, + 경로, + 이름: 경로.join(" "), + 단위: "m", + 도번: `SD-${열}`, + 면적번: `SA-${열}`, + 수정일: { structure: "2026-10-01T09:00:00", area: "2026-10-03T10:00:00" }, + labor: null, + ...extra, +}); +const rows = [ + row("c1", ["기슭막이", "메쌓기", "1.2m"], { + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, + }), + row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), + row("c3", ["옹벽"], { 도번: "", 수정일: {} }), +]; +const cells = (kind) => rows.map((r) => m.ITEM_COLS[kind].map((c) => c.get(r))); +const many = Array.from({ length: 65 }, (_, i) => row(`n${i}`, ["배수", `측구 ${i}`])); + +process.stdout.write( + JSON.stringify({ + heads: Object.fromEntries( + Object.entries(m.ITEM_COLS).map(([k, cols]) => [k, cols.map((c) => c.head)]), + ), + cells: { + structure: cells("structure"), + area: cells("area"), + labor: cells("labor"), + item: cells("item"), + }, + find: m.pageOf(rows, m.ITEM_COLS.structure, "기슭 찰", 1).shown.map((r) => r.열), + findLabor: m.pageOf(rows, m.ITEM_COLS.labor, "SU-000001", 1).shown.map((r) => r.열), + findNone: m.pageOf(rows, m.ITEM_COLS.structure, "없는말", 1), + page3: (({ page, total, shown }) => ({ page, total, n: shown.length }))( + m.pageOf(many, m.ITEM_COLS.structure, "", 3), + ), + pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, + pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + }), +); diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py new file mode 100644 index 000000000..63b809bd7 --- /dev/null +++ b/resources/tester/test_m02_item_list.py @@ -0,0 +1,94 @@ +"""M02 항목 목록(PLAN 39-5) — 칸 · 찾기 · 쪽(TS · `helper_m02_item_list.cjs`) · +서버 목록 칸(임시 storage). + +① 종류마다 칸(도면 · 면적산출 · 일위대가 · 항목 통째) · 빈 번호 「—」 · 합계 없으면 「—」 · + 고친 날 = 날짜만 · 찾기 = 보이는 칸 AND · 쪽 30 줄 · 넘친 쪽은 끝 쪽. +② 서버 `items` — `수정일`(도면 · 면적산출 · 일위대가 파일) · `labor`(번호 · 담은 로직 수 · + 합계 = 계산값.반영합.계) · 일위대가 파일이 없으면 null · 깨진 파일 · 수 아닌 합계도 목록은 뜸. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +from resources.tester import test_m02_tree as base +from resources.tester.test_m02_tree import Tree, _as + +world = base.world # 같은 임시 storage 판 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_item_list.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종류마다_칸(ts): + assert ts["heads"]["structure"] == ["도번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["area"] == ["면적번", "이름", "폴더", "단위", "고친 날"] + assert ts["heads"]["labor"] == [ + "일위대가번", + "이름", + "폴더", + "단위", + "담은 로직 수", + "합계", + "고친 날", + ] + assert "규격" not in sum(ts["heads"].values(), []) and "층" not in sum(ts["heads"].values(), []) + + +def test_칸_값_빈_번호_합계_날짜(ts): + s, labor = ts["cells"]["structure"], ts["cells"]["labor"] + assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] + assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) + assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 + assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 + + +def test_찾기_쪽(ts): + assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) + assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 + assert ts["findNone"] == {"shown": [], "page": 1, "total": 0} + assert ts["page3"] == {"page": 3, "total": 65, "n": 5} + assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 + + +def test_서버_목록_칸_수정일_labor(world): + t = Tree(_as(world), "personal") + t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") + t.do(op="add-item", 경로=[], 이름="옹벽", 단위="m") + root = world["storage"] / "7" / "42" / "templates" + ditch, wall = t.item(["배수", "측구"]), t.item(["옹벽"]) + assert set(ditch["수정일"]) >= {"structure", "area"} + assert len(ditch["수정일"]["structure"]) == 19 # 초 단위 ISO + assert ditch["labor"] is None # 일위대가 파일 없음 + (root / "labor").mkdir(exist_ok=True) + doc = { + "일위대가번": "SU-000001", + "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], + "계산값": {"반영합": {"계": 12345.6}}, + } + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") + rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert "labor" in rows[ditch["열"]]["수정일"] + assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + doc["계산값"] = {"반영합": {"계": "글"}} + (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") + again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} + assert again[ditch["열"]]["labor"]["합계"] is None diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 1be487e98..9398233f1 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -77,7 +77,13 @@ def test_시스템_층은_그대로_항목_목록은_SS_줄(world): rows = client.get("/api/m02/items").json() ss = store.read_combos()["줄"] assert [r["key"] for r in rows] == [r["키"] for r in ss] and rows[0]["key"] == "SS000001" - assert set(rows[0]) == {"key", "names", *items_router.items_store.ITEM_FIELDS} + assert set(rows[0]) == { + "key", + "names", + "수정일", # 목록 칸(39-5) + "labor", + *items_router.items_store.ITEM_FIELDS, + } assert set(rows[0]["names"]) == {"일위대가", "자재조합", "도번", "면적번", "재료번"} assert client.get("/api/m02/layers/system/structure-combos").json() == store.read_combos() # 시스템 층 쓰기는 SYSTEM_ADMIN 만 From 134a727c9cf6e98832496cc162f4faf3b87225c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:27:02 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat(M02):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20M01=20=EB=A8=B8=EB=A6=AC=20=EA=BC=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=A0=9C=EB=AA=A9=2018px=20=EC=99=BC=EC=AA=BD=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0=E3=80=8D?= =?UTF-8?q?=20=EB=8A=94=20=EC=A0=9C=EB=AA=A9=20=EC=95=9E=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 마스터 템플릿 상세 머리 전부(구조물 조합 · 도면 · 면적산출 · 일위대가 · 양식 밖 화면). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_UI_Style.css | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 480f213f2..522751dc9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -152,7 +152,8 @@ export function createMain( ...(extras.top ? [extras.top] : []), el("div", { className: "m02-main__head", - children: [title, back, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], + // M01 머리 꼴(39-5) — ← 는 제목 앞 · 제목 18px 왼쪽 · 단추는 오른쪽(slot 이 늘어나 밂) + children: [back, title, slot, reload, save, ...(extras.newItem ? [extras.newItem] : [])], }), ...(extras.search ? [extras.search] : []), notice, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0e323ca8c..4b4fa823a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -42,7 +42,7 @@ overflow-wrap: anywhere; /* 번호 + 이름 — 잘리지 않게 줄바꿈 */ margin: 0; color: var(--color-text); - font-size: var(--text-body); + font-size: 18px; /* M01 머리 제목과 같음(39-5) */ } .m02-main__head .ui-btn { From 80a635d3b9c62017266ec9b6a78c06444089aaa2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:30:41 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EC=84=A0=20=C2=B7=20=ED=95=98=EC=9C=84=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=A0=84=EC=B2=B4=20=ED=95=AD=EB=AA=A9=E3=80=8D=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20onAll=20=EC=86=90=EC=9E=A1=EC=9D=B4(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층 단추 · 나무 → 수평선 → 구조물 도면 → 일위대가 → 면적산출(재료산출 · 자재 조합은 sub6 몫으로 그대로) - 하위마다 맨 위 「전체 항목」 — 누르면 SideOptions.onAll(kind)(화면은 39-5) · 양식 · 다른 보기가 열리면 고름 지움 - 이름 「일위대가」(하위 제목 · 대시보드 카드 글) · 해시 open=일위대가 = 일위대가 하위 펼침 + 전체 항목 - 일위대가 열 줄은 SU 번호(39-1a) 뒤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_UI_Side.ts | 83 +++++++++++++++---- .../M02_MasterTemplete_UI_Style.css | 7 ++ resources/tester/test_m02_labor_tree.py | 16 ++++ .../tester/test_m02_structure_options.py | 16 ++-- resources/tester/test_m02_structure_panel.py | 21 +++-- ui_template/ui_template_locale_m2.ts | 4 +- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f2133c829..691426e65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · - * 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`. + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무 → 수평선 → 하위 구조물 도면 · 일위대가 · + * 면적산출 · 재료산출 · 자재 조합 · 하위마다 맨 위 「전체 항목」) · 「도면 양식」(새로 · 본떠 · 지우기). + * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. @@ -46,13 +47,22 @@ const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, { kind: "drawing", label: () => L("M02_KindDrawing") }, ]; -/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */ -type ColumnKind = "structure" | "area" | "material"; -const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [ - { kind: "structure", key: "도번", label: () => L("M02_KindStructure") }, - { kind: "area", key: "면적번", label: () => L("M02_KindArea") }, - { kind: "material", key: "재료번", label: () => L("M02_KindMaterial") }, +/** 구조물 열마다 한 줄씩인 하위 목록 — 종류 · 번호 칸(null = 열 줄 아직 없음) · 제목 · + * `all` = 맨 위 「전체 항목」 줄(PLAN 39-2) · 차례 = 구조물 도면 → 일위대가 → 면적산출 */ +type ColumnKind = "structure" | "labor" | "area" | "material"; +const COLUMN_KINDS: { + kind: ColumnKind; + key: keyof StructureNumbers | null; + label: () => string; + all: boolean; +}[] = [ + { kind: "structure", key: "도번", label: () => L("M02_KindStructure"), all: true }, + { kind: "labor", key: null, label: () => L("M02_KindLabor"), all: true }, + { kind: "area", key: "면적번", label: () => L("M02_KindArea"), all: true }, + { kind: "material", key: "재료번", label: () => L("M02_KindMaterial"), all: false }, ]; +/** 「전체 항목」 줄이 있는 하위 — 누르면 H 목록 화면(`onAll`) */ +export type AllKind = "structure" | "labor" | "area"; /** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */ const STUB_DRAWING = { format: 6, @@ -75,6 +85,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 하위 맨 위 「전체 항목」 줄 — 메인 칸에 그 하위 목록(화면 = `ItemList` · 페이지가 이음) */ + onAll?: (kind: AllKind) => void; } export interface SideHandle { @@ -102,6 +114,8 @@ export function buildSide(opt: SideOptions): SideHandle { let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; let numbers: Record = {}; + /** 지금 연 「전체 항목」 — 양식 · 다른 보기 화면이 열리면 지움 */ + let allPicked: AllKind | null = null; let seq = 0; const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => @@ -133,7 +147,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -145,7 +159,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -154,12 +168,14 @@ export function buildSide(opt: SideOptions): SideHandle { }); return sec; }; - // 구조물 집계 양식 — 층 단추 · 나무 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · + const columnSections = COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])); + // 구조물 집계 양식 — 층 단추 · 나무 → 수평선 → 하위 컨테이너(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) · // 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6) const summary = section( L("M02_KindTable"), el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }), - ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), + el("hr", { className: "m02-side__rule" }), + ...columnSections, matsetSection, ); summary.classList.remove("is-collapsed"); @@ -219,9 +235,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 · 면적산출 · 재료산출 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ - function paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void { + /** 맨 위 「전체 항목」 줄(PLAN 39-2) — 누르면 그 하위 목록(`onAll`) */ + function allRow(kind: AllKind): HTMLElement { + const b = el("button", { + className: `m02-side__item m02-side__all ui-sidebar-row${allPicked === kind ? " is-active" : ""}`, + text: L("M02_ItemsAll"), + attrs: { type: "button", "data-all": kind }, + }); + b.addEventListener("click", () => void selectAll(kind)); + return b; + } + + /** 하위 줄 — 「전체 항목」 + 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, k: (typeof COLUMN_KINDS)[number]): void { + const { key } = k; + const head = k.all ? [allRow(k.kind as AllKind)] : []; + if (key === null) return body.replaceChildren(...head); + const kind = k.kind as Kind; // 번호 칸이 있는 하위만 — 일위대가 열 줄은 번호(SU)가 선 뒤 body.replaceChildren( + ...head, ...(columns.length ? columns.map((col) => { const num = numbers[col.id]?.[key] || L("M02_StructureNone"); @@ -236,8 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } - const paintColumns = (): void => - COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key)); + const paintColumns = (): void => COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k)); /** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */ function paintButtons(): void { @@ -248,6 +279,7 @@ export function buildSide(opt: SideOptions): SideHandle { /** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음 * (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */ function paint(): void { + if (opt.getOpen()) allPicked = null; paintList(); paintColumns(); paintButtons(); @@ -255,13 +287,22 @@ export function buildSide(opt: SideOptions): SideHandle { if (opt.getOpen()) options.clear(); } + async function selectAll(kind: AllKind): Promise { + if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; + matset.clear(); + options.clear(); + allPicked = kind; + opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 + paint(); + } + // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); } - async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { + async function selectColumn(col: StructureColumn, kind: Kind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); @@ -377,7 +418,13 @@ export function buildSide(opt: SideOptions): SideHandle { } const reveal = (target: "옵션" | "도면" | "일위대가"): void => { - if (target === "일위대가") return; // 구조물 조합은 처음부터 펼침 + // 일위대가 = 구조물 조합(처음부터 펼침) 안 일위대가 하위를 펼치고 「전체 항목」 + if (target === "일위대가") { + columnSections[COLUMN_KINDS.findIndex((k) => k.kind === "labor")].classList.remove( + "is-collapsed", + ); + return void selectAll("labor"); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c4c51d1ca..aa14b148e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -15,6 +15,13 @@ margin-left: var(--spacing-8); } +/* 구조물 조합 안 층 단추 · 나무와 하위 컨테이너 사이 수평선(PLAN 39-2) */ +.m02-side__rule { + margin: var(--spacing-8) 0; + border: 0; + border-top: 1px solid var(--color-border); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 755dac40b..0776b560f 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -29,6 +29,22 @@ def test_나무_부품에_일위대가_전용_칸_없음(): assert gone not in panel, gone +def test_하위_차례_전체_항목_이름(): + side = _text("M02_MasterTemplete_UI_Side.ts") + kinds = side.split("const COLUMN_KINDS")[1].split("];")[0] + order = [kinds.index(f'kind: "{k}"') for k in ("structure", "labor", "area")] + assert order == sorted(order) # 구조물 도면 → 일위대가 → 면적산출 + assert 'label: () => L("M02_KindLabor"), all: true' in kinds + assert "onAll?: (kind: AllKind) => void;" in side # 화면은 ItemList(39-5) · 페이지가 이음 + assert "if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return;" in side + assert 'return void selectAll("labor");' in side # 해시 open=일위대가 + locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") + assert 'M02_KindLabor: ["일위대가"' in locale and 'M02_ItemsAll: ["전체 항목"' in locale + assert 'B01_Dashboard_CardUnitCombo: ["일위대가",' in locale + css = _text("M02_MasterTemplete_UI_Style.css") + assert ".m02-side__rule {" in css and "border-top: 1px solid var(--color-border);" in css + + def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 6df8d180b..5d1a98e5e 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,17 +120,11 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 일위대가 조합(27-2b) → 구조물 조합 → 옵션 → 도면 양식 - order = [ - "...(labor ? [openOnExpand(section(labor.title, labor.body), () => void labor.open())]" - " : []),", - " summary,", - " ...(extras ? [extras.옵션, extras.도면] : []),", - ] - assert "\n".join(order) in side - # 자재 조합을 열면 이 줄 · 일위대가 고름 지움 / 이 줄을 열면 자재 조합 · 일위대가 고름 지움 - assert "onOpen: () => (options.clear(), labor?.clear(), paint())" in side - assert "onOpen: () => (matset.clear(), labor?.clear(), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 + assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side + # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 + assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index bb9b3341f..b10a492d8 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -154,9 +154,16 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 assert "createSelectField" not in side # 새로 = 종류 고르기 없이 이름만 assert "cur?.종류 !== kind" in side # 본떠 · 지우기 = 그 컨테이너에서 고른 것이 있을 때만 assert "i.이름 === STRUCTURE_TABLE" in side # 표는 구조물집계표 한 줄만 - # 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) + # 구조물 도면 · 일위대가 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) · + # 층 단추 · 나무와 하위 사이 수평선(39-2) summary = side.split("const summary = section(")[1].split(");")[0] - assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary + assert ( + summary.index("opt.tree") + < summary.index("m02-side__rule") + < summary.index("...columnSections") + ) + assert "matsetSection" in summary + assert "const columnSections = COLUMN_KINDS.map(" in side assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) @@ -184,7 +191,11 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" side = _text(SIDE) assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 - assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + assert re.search( + r"function paint\(\): void \{\s*if \(opt\.getOpen\(\)\) allPicked = null;\s*" + r"paintList\(\);\s*paintColumns\(\);", + side, + ) # 양식이 열리면 「전체 항목」 고름도 지움(39-2) for fn in ("async function select(", "async function selectColumn("): body = side.split(fn)[1].split("\n }\n")[0] assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 @@ -192,8 +203,8 @@ def test_좌측_고름_표시는_열린_것_한_줄뿐(): # 자재 조합 상세도 같은 한 곳 — 열면 양식 · 열 줄 고름 지움 · 양식을 열면 자재 조합 고름 지움 assert re.search(r"sel = null;\s*onOpened\?\.\(\);", _text(MAIN)) assert "if (opt.getOpen()) matset.clear();" in side.split("function paint(): void {")[1] - # 편집 칸이 열 때마다 좌측 `paint`(27-2b 뒤 = 일위대가 화면 해시 맞춤도 같은 자리) - assert re.search(r"\(\) => \{\s*side\.paint\(\);", _text(M02 / "M02_MasterTemplete_UI_Page.ts")) + # 편집 칸이 열 때마다 좌측 `paint`(일위대가 화면 해시 맞춤은 39-2 에서 지움) + assert "() => side.paint()," in _text(M02 / "M02_MasterTemplete_UI_Page.ts") def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index eff4717b9..1058adaa6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -4,7 +4,7 @@ * ========================================================================== */ export const ui_locales_m2 = { - B01_Dashboard_CardUnitCombo: ["일위대가 조합", "Unit cost combos"], + B01_Dashboard_CardUnitCombo: ["일위대가", "Unit costs"], B01_Dashboard_CardStructCombo: ["구조물 조합", "Structure combos"], B01_Dashboard_CardStructOption: ["구조물 옵션", "Structure options"], B01_Dashboard_CardDrawing: ["도면 양식", "Drawing templates"], @@ -172,4 +172,6 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_KindLabor: ["일위대가", "Unit costs"], + M02_ItemsAll: ["전체 항목", "All items"], } as const; From eb132f13d0d86d8add08b58e0c90b9228a2d2567 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:31:11 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20ItemPick=20?= =?UTF-8?q?=E2=80=94=20=ED=83=AD(=EB=A7=88=EC=8A=A4=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=ED=9A=8C=EC=82=AC=20=C2=B7=20=EA=B0=9C=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=EB=AC=B4=EB=AC=B8=EC=84=9C)=20+=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=B9=B8=20=EC=95=88=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BD=20=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H3 — 탭 목록 · 기본 탭 = readableSources(39-6 E5) · 다른 사람 = 사람 고르기 하나 · 실무문서 = 마스터 일위대가 조합(일위대가 고를 때). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 15 ++ .../M02_MasterTemplete_ItemPick.ts | 178 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css new file mode 100644 index 000000000..70f57c665 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -0,0 +1,15 @@ +/* 가져올 곳 고르기(PLAN 39-5) — 탭 줄 + 목록 · 창이면 목록이 넓게 */ +.m02-itempick { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick .m01-master__grid-wrap { + max-height: min(60vh, calc(100vh - 300px)); +} + +.ui-modal.m02-itempick__dialog { + width: min(960px, 94vw); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts new file mode 100644 index 000000000..dc1fbc122 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * M02_MasterTemplete_ItemPick.ts + * 가져올 곳 고르기(PLAN 39-5) — 탭(마스터 · 회사 · 개인 · 다른 사람 · 실무문서) + 항목 목록(`ItemList`). + * 복사 생성(상세 칸 안 · `buildPicker`) · 복사해 가져오기(창 · `pickFrom`)가 같이 씀. + * 탭 목록 · 기본 탭 = `readableSources`(권한 몫 · `M02_MasterTemplete_Layer.ts`) · 탭은 목록만 바꿈 + * (좌측 층 단추와 달리 화면을 다시 안 세움) · 모양은 층 단추(`m02-layers__btn`) 그대로. + * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). + * ========================================================================== */ + +import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + buildItemList, + ITEM_COLS, + loadItems, + type Col, + type ItemRow, + type ListKind, +} from "./M02_MasterTemplete_ItemList"; +import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; +import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; +import "./M02_MasterTemplete_TreePanel.css"; +import "./M02_MasterTemplete_ItemPick.css"; + +const TEXT = { + close: "닫기", + person: "사람", + noPeople: "다른 사람이 없음", + failed: "목록을 못 받음 — {value}", +}; + +/** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ +export interface From { + layer: Layer; + owner: string | null; + label: string; + practice: boolean; +} + +export type Picked = { from: From; row: ItemRow } | { from: From; combo: ComboSummary }; + +/** 고른 것 이름 — 확인 글에 씀 */ +export const pickedName = (p: Picked): string => + "row" in p ? leafName(p.row) : `${p.combo.키} ${p.combo.이름}`; + +const PRACTICE_COLS: Col[] = [ + { head: "키", get: (c) => c.키 }, + { head: "이름", get: (c) => c.이름 }, + { head: "구분", get: (c) => c.구분 ?? "" }, + { head: "상세구분", get: (c) => c.상세구분 ?? "" }, + { head: "단위", get: (c) => c.단위 ?? "" }, + { head: "담은 로직 수", get: (c) => String(c.count) }, +]; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface PickerOptions { + user: SessionUser | null; + /** 볼 회사(`viewCompany`) */ + company: string | null; + /** 칸 종류 — 항목 통째(복사 생성) · 도면 · 면적산출 · 일위대가(실무문서 탭이 붙음) */ + kind: ListKind; + /** 고를 수 없는 줄(받는 열 자신 등) — 목록에서 뺌 */ + skip?: (from: From, row: ItemRow) => boolean; + onPick: (picked: Picked) => void; +} + +/** 탭 + 목록 — 부르는 쪽이 칸 안 · 창에 붙임 */ +export function buildPicker(opt: PickerOptions): { root: HTMLElement } { + const { tabs, first } = readableSources(opt.user, opt.company, { + practice: opt.kind === "labor", + }); + const list = buildItemList(); + const people = createSelectField({ options: [], compact: true, label: TEXT.person }); + people.root.hidden = true; + let owners: { owner: string; name: string }[] | null = null; + let seq = 0; + + const buttons = tabs.map((tab) => { + const b = el("button", { + className: "m02-layers__btn", + text: tab.label, + attrs: { type: "button", "data-source": tab.id }, + }); + b.addEventListener("click", () => void show(tab)); + return b; + }); + + async function show(tab: Source): Promise { + const mine = ++seq; + buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.source === tab.id)); + people.root.hidden = tab.id !== "people"; + list.message(); + try { + if (tab.id === "practice") { + const combos = await fetchCombos(); + if (mine !== seq) return; + const from = { layer: tab.layer, owner: null, label: tab.label, practice: true }; + return list.show(combos, PRACTICE_COLS, (combo) => opt.onPick({ from, combo })); + } + let owner = tab.owner; + let label = tab.label; + if (tab.id === "people") { + owners ??= await fetchOwners("personal", opt.company); + if (mine !== seq) return; + if (!owners.length) return list.message(TEXT.noPeople); + if (!people.select.value) { + people.setOptions( + owners.map((o) => ({ value: o.owner, text: o.name })), + owners[0].owner, + ); + } + owner = people.select.value; + label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + } + const rows = await loadItems(tab.layer, owner); + if (mine !== seq) return; + const from = { layer: tab.layer, owner, label, practice: false }; + list.show( + rows.filter((row) => !opt.skip?.(from, row)), + ITEM_COLS[opt.kind], + (row) => opt.onPick({ from, row }), + ); + } catch (error) { + if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); + } + } + + people.select.addEventListener("change", () => { + const tab = tabs.find((t) => t.id === "people"); + if (tab) void show(tab); + }); + const start = tabs.find((t) => t.id === first) ?? tabs[0]; + if (start) void show(start); + return { + root: el("div", { + className: "m02-itempick", + children: [ + el("div", { className: "m02-layers__row", children: [...buttons, people.root] }), + list.root, + ], + }), + }; +} + +/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +export function pickFrom( + opt: Omit & { title: string; ask?: (picked: Picked) => string }, +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Picked | null): void => { + if (!done) resolve(value); + done = true; + }; + openModal({ + title: opt.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-itempick__dialog", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const picker = buildPicker({ + ...opt, + onPick: (picked) => + void (async () => { + if (opt.ask && !(await showConfirmDialog(opt.ask(picked)))) return; + finish(picked); + close(); + })(), + }); + body.append(picker.root); + }, + }); + }); +} From af92f0553e7c58009e3b449e88745dc64763791c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:34:10 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(M02):=20=E3=80=8C=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=ED=95=AD=EB=AA=A9=E3=80=8D=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=8F=84=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=95=98=EC=9C=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=83=81=EC=84=B8=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EB=88=84=EB=A6=84=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H2 — 좌측 onAll(39-2) 이음 · 제목 「하위 · 전체 항목」 · 목록 = ItemList(M01 꼴). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 17 ++++++++++++ .../M02_MasterTemplete_UI_Page.ts | 26 +++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 363c48e5c..4d015ca6b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -35,6 +35,7 @@ const TEXT = { none: "항목이 없음", noMatch: "맞는 항목이 없음", loading: "불러오는 중", + failed: "목록을 못 받음", }; const SIZE = 30; @@ -111,6 +112,22 @@ export function pageOf( export const loadItems = async (layer: Layer, owner: string | null): Promise => (await fetchTree(layer, owner)).items as ItemRow[]; +/** 「전체 항목」 화면(39-5) — 한 층 · 한 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄 누름과 같음) */ +export function buildAllItems( + kind: ListKind, + layer: Layer, + owner: string | null, + onOpen: (row: ItemRow) => void, +): HTMLElement { + const list = buildItemList(); + loadItems(layer, owner).then( + (rows) => list.show(rows, ITEM_COLS[kind], onOpen), + (error: unknown) => + list.message(`${TEXT.failed} — ${error instanceof Error ? error.message : String(error)}`), + ); + return list.root; +} + export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index a19bc482a..9b792a9ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -41,9 +41,17 @@ import { seesOthers, } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; -import { buildSide } from "./M02_MasterTemplete_UI_Side"; +import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; +import { buildAllItems } from "./M02_MasterTemplete_ItemList"; import "./M02_MasterTemplete_UI_Style.css"; +/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ +const ALL_TITLE = { + structure: "M02_KindStructure", + labor: "M02_KindLabor", + area: "M02_KindArea", +} as const satisfies Record; + export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); @@ -155,6 +163,20 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], + // 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`) + onAll: (kind) => + void main.showView( + `${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`, + buildAllItems(kind, layer, owner, (row) => { + void main.open({ + layer, + projectId: null, + kind: kind as Kind, + name: row.열, + label: row.이름, + }); + }), + ), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); From 516719d50d6a5b7cca9b83acfd8b46865f86aa5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:44 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat(M02):=20=EC=B8=B5=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=E3=80=8C=ED=9A=8C=EC=82=AC=20=EB=A7=A1=EC=9D=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=20=C2=B7=20=EA=B7=B8=20=ED=9A=8C=EC=82=AC=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EC=B8=B5=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EB=82=A8=EC=9D=98?= =?UTF-8?q?=20=EC=B8=B5=20=EC=B2=AB=20=EC=93=B0=EA=B8=B0=20=EC=86=8C?= =?UTF-8?q?=EC=86=8D=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B8=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=84=EB=B6=80=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(39-6=20E1~E4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- M02_MasterTemplete/M02_Layer_Access.py | 39 +++++----- .../M02_MasterTemplete_Router_Combos.py | 14 +++- .../M02_MasterTemplete_Router_Items.py | 55 +++++++++++++- .../M02_MasterTemplete_Router_Layers.py | 39 +++++++--- resources/tester/test_m02_combo_layers.py | 20 ++++++ resources/tester/test_m02_layer_access.py | 71 ++++++++++++++++--- resources/tester/test_m02_template_layers.py | 34 +++++++-- 7 files changed, 227 insertions(+), 45 deletions(-) diff --git a/M02_MasterTemplete/M02_Layer_Access.py b/M02_MasterTemplete/M02_Layer_Access.py index fe1b71430..2a0cc5dc8 100644 --- a/M02_MasterTemplete/M02_Layer_Access.py +++ b/M02_MasterTemplete/M02_Layer_Access.py @@ -1,13 +1,12 @@ """M02 층 권한 한 벌 — 나무 · 항목(`Router_Items`) · 양식 · 남의 층 목록(`Router_Layers`)이 함께 씀 (PLAN 27-1c). 화면 쪽 짝 = `M02_MasterTemplete_Layer.ts` 의 `accessFor`. +「회사 C 를 맡음」(`manages` · 39-6) = SYSTEM_ADMIN · 또는 C 의 회사 관리자(ADMIN · 마스터). system 읽기 = 로그인 누구나 · 수정 = SYSTEM_ADMIN - company {C} 자기 회사 = 읽기 누구나 · 수정 = 회사 관리자(ADMIN · 마스터 · SYSTEM_ADMIN) · - 남의 회사 = SYSTEM_ADMIN 읽기만 - personal {C}/{U} 본인 = 읽기 · 수정 · 같은 회사 남 = 회사 관리자 읽기만 · - 다른 회사 = SYSTEM_ADMIN 읽기만 -owner 글 없음 = 자기 층 · 남의 층은 그 층 폴더가 있을 때만(없으면 404) · -읽기로 남의 층에 뼈대를 안 만듦. + company {C} 읽기 = C 사람 · SYSTEM_ADMIN · 수정 = C 를 맡은 사람 + personal {C}/{U} 본인 = 읽기 · 수정 · 남 = C 를 맡은 사람만 읽기 · 수정(일반 = 403) +owner 글 없음 = 자기 층 · 남의 층 읽기는 그 층 폴더가 있을 때만(없으면 404) · +읽기로 남의 층에 뼈대를 안 만듦(쓰기는 부르는 쪽이 소속을 본 뒤 `folder(…, create=True)`). """ from __future__ import annotations @@ -33,6 +32,14 @@ def is_company_admin(session: dict[str, Any]) -> bool: return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master")) +def manages(session: dict[str, Any], company: Any) -> bool: + """회사 `company` 를 맡음 — 그 회사 층 · 그 회사 모든 개인 층을 보고 고침.""" + if is_system_admin(session): + return True + own = session.get("company_id") + return own is not None and str(own) == str(company) and is_company_admin(session) + + @dataclass(frozen=True) class Grant: """층 한 칸의 권한 — `owner` = 층 주인 글(시스템 None) · `mine` = 자기 층(뼈대 만들어도 됨).""" @@ -63,15 +70,14 @@ def grant(session: dict[str, Any], layer: str, owner: str | None = None) -> Gran raise HTTPException(status_code=409, detail=NO_COMPANY) if not _SHAPE[layer].match(who): raise HTTPException(status_code=400, detail=f"층 주인 모양이 틀림 「{who}」") + company = who.split("/")[0] if who == own: - return Grant(layer, who, layer == "personal" or is_company_admin(session), True) - same_company = who.split("/")[0] == str(session.get("company_id")) - colleague = layer == "personal" and same_company and is_company_admin(session) - if not (is_system_admin(session) or colleague): + return Grant(layer, who, layer == "personal" or manages(session, company), True) + if not manages(session, company): raise HTTPException( status_code=403, detail="다른 회사 · 다른 사람의 구조물 집계표는 볼 수 없음" ) - return Grant(layer, who, False, False) + return Grant(layer, who, True, False) def require( @@ -82,16 +88,15 @@ def require( if write and not got.write: if layer == "system": detail = "마스터 템플릿은 시스템 관리자만 고침." - elif got.mine: - detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." else: - detail = "남의 층은 읽기만." + detail = "회사용은 회사 관리자만 고침 — 개인용으로 복사해서 씀." raise HTTPException(status_code=403, detail=detail) return got -def folder(got: Grant) -> Path: - """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함(부르는 쪽은 자기 층만 뼈대를 만듦).""" +def folder(got: Grant, create: bool = False) -> Path: + """회사 · 개인 층 폴더 — 남의 층은 그 층 폴더가 있어야 함 · `create` = 쓰기(부르는 쪽이 소속을 + 본 뒤 뼈대를 만듦).""" if got.owner is None: raise HTTPException(status_code=409, detail=NO_COMPANY) try: @@ -102,6 +107,6 @@ def folder(got: Grant) -> Path: path = layers.personal_dir(company, user) except ValueError as error: raise HTTPException(status_code=400, detail=f"층 주인 모양 — {error}") from error - if not got.mine and not path.is_dir(): + if not (got.mine or create) and not path.is_dir(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return path diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..8495ad84c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -7,13 +7,14 @@ 저장소 = `M02_MasterTemplete_Combo_Store`. 담을 로직 고르기 · 미리 보기가 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. +39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). """ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from common_util.common_util_auth import verify_session @@ -23,7 +24,16 @@ from M02_MasterTemplete import M02_MasterTemplete_Combo_Copy as combo_copy from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root -router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Combos"]) + +def _door(request: Request, session: Session = Depends(verify_session)) -> None: + """일위대가 조합 = 실무문서(39-6) — 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + if "/logic-lib/" not in request.url.path and not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서(일위대가 조합)는 시스템 관리자만 봄.") + + +router = APIRouter( + prefix="/api/m02", tags=["M02 MasterTemplete Combos"], dependencies=[Depends(_door)] +) class ComboBody(BaseModel): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 4321aee0f..0a40524d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -10,13 +10,17 @@ PLAN 27-1c) · `owner` = 회사 `{C}` · 개인 `{C}/{U}`(없으면 자기 층) from __future__ import annotations +import logging from pathlib import Path from typing import Any, Literal +import anyio.from_thread from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel +from common_util.common_util_audit import record_audit from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool from M02_MasterTemplete import M02_Item_Paste as paste_store from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Branch as branch @@ -32,6 +36,7 @@ from M02_MasterTemplete.M02_MasterTemplete_Router import ( UseComboBody, ) +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Items"]) Layer = Literal["system", "company", "personal"] @@ -74,14 +79,60 @@ class CopyItemBody(BaseModel): key: str +async def _member(owner: str) -> bool: + """층 주인이 있나 — 회사 `{C}` = 그 회사 · 개인 `{C}/{U}` = U 가 C 소속.""" + company, _, user = owner.partition("/") + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + if user: + await cursor.execute( + "SELECT 1 FROM users WHERE id = %s AND company_id = %s AND deleted_at IS NULL", + (int(user), int(company)), + ) + else: + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (int(company),) + ) + return await cursor.fetchone() is not None + + +async def _note(session: Session, got: access.Grant) -> None: + """남의 층 쓰기 한 줄(39-6 · 시스템 로그) — 기록이 못 돼도 쓰기는 막지 않음.""" + try: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await record_audit( + cursor, + actor_id=int(session["user_id"]), + action="M02_OTHER_LAYER_WRITE", + resource_type=f"m02_{got.layer}", + resource_ref=got.owner, + ) + await connection.commit() + except Exception: + logger.exception("M02 남의 층 쓰기 기록 실패: %s %s", got.layer, got.owner) + + +async def others_write(session: Session, got: access.Grant, folder: Path) -> None: + """남의 층 쓰기 — 처음(집계표 없음)이면 주인이 있는지 본 뒤 뼈대 · 쓸 때마다 시스템 로그.""" + if not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): + if not await _member(str(got.owner)): + raise HTTPException(status_code=403, detail="그 회사 · 그 회사 사람이 아님") + items_store.ensure(folder) + await _note(session, got) + + def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path: - """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦.""" + """층 폴더(권한까지 · `M02_Layer_Access`) — 자기 회사 · 개인 층은 없으면 빈 뼈대를 만듦 · + 남의 층 쓰기(맡은 회사 · 39-6)는 `others_write`.""" got = access.require(session, layer, owner, write=write) if layer == "system": return store.FOLDER - folder = access.folder(got) + folder = access.folder(got, create=write) if got.mine: items_store.ensure(folder) + elif write: + anyio.from_thread.run(others_write, session, got, folder) elif not (folder / "table" / f"{store.STRUCTURE_TABLE}.json").is_file(): raise HTTPException(status_code=404, detail="그 층은 아직 만든 것이 없음") return folder diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index a9d65b1ab..f3097f50b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers @@ -164,8 +165,10 @@ def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int: async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None: + """남의 개인 양식 = 그 회사를 맡은 사람만(39-6 · 층 길과 한 규칙) · 그 회사 사람이어야 함.""" if user_id == session.get("user_id"): return + access.grant(session, "personal", f"{company_id}/{user_id}") if await _user_company(user_id) != company_id: raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.") @@ -192,7 +195,12 @@ async def _layer_dir( who = session["user_id"] if user_id is None else user_id owner = str(company_id) if layer == "company" else f"{company_id}/{who}" got = access.require(session, layer, owner, write=write) - return _ensured(access.folder(got)) if got.mine else access.folder(got) + folder = access.folder(got, create=write) + if got.mine: + return _ensured(folder) + if write: # 남의 층 쓰기(맡은 회사 · 39-6) — 구조물 길과 한 벌 + await items_router.others_write(session, got, folder) + return folder def _ensured(folder: Path) -> Path: @@ -214,9 +222,9 @@ async def list_owners( company: int | None = Query(None), session: dict[str, Any] = Depends(verify_session), ) -> list[dict[str, Any]]: - """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]` · 층 자리가 있는 것만(본인 뺌). - 회사 = SYSTEM_ADMIN 만 회사 목록 · 개인 = 그 회사 사람(SYSTEM_ADMIN 은 어느 회사나 · - 회사 관리자는 자기 회사만).""" + """남의 층 목록(PLAN 27-1c · 펼칠 때 부름) — `[{owner, name}]`. + 회사 = SYSTEM_ADMIN 만 · 층 자리가 있는 것만(자기 회사는 늘) · + 개인 = 그 회사를 맡은 사람(39-6)만 · 회사 사람 전부(나 맨 위 · 집계표 없으면 `비어있음`).""" if layer == "company": if not access.is_system_admin(session): raise HTTPException(status_code=403, detail="회사 목록은 시스템 관리자만 봄.") @@ -225,12 +233,16 @@ async def list_owners( target = session.get("company_id") if company is None else company if target is None: raise HTTPException(status_code=409, detail=access.NO_COMPANY) - mine = str(target) == str(session.get("company_id")) - if not (access.is_system_admin(session) or (mine and access.is_company_admin(session))): + if not access.manages(session, target): raise HTTPException(status_code=403, detail="같은 회사 사람 목록은 회사 관리자만 봄.") me = session.get("user_id") users = await _company_users(int(target)) - rows = [(f"{target}/{u['user_id']}", u["name"]) for u in users if u["user_id"] != me] + users.sort(key=lambda u: u["user_id"] != me) # 나 맨 위 + return [ + {"owner": owner, "name": u["name"], "비어있음": _empty(owner)} + for u in users + for owner in [f"{target}/{u['user_id']}"] + ] found = [] for owner, name in rows: got = access.Grant(layer, owner, False, owner == access.own_owner(session, layer)) @@ -242,6 +254,13 @@ async def list_owners( return found +def _empty(owner: str) -> bool: + """개인 층에 집계표가 없음 — 사람 나무 「비어 있음」(폴더를 안 만듦).""" + company, user = owner.split("/") + table = layers.personal_dir(company, user) / "table" / f"{store.STRUCTURE_TABLE}.json" + return not table.is_file() + + @router.get("/layers/{layer}/templates") async def list_layer( layer: Layer, @@ -519,7 +538,11 @@ async def list_sources( ) return rows - users = await _company_users(company_id) + users = [ # 남의 개인 = 그 회사를 맡은 사람만(39-6) + u + for u in await _company_users(company_id) + if u["user_id"] == session.get("user_id") or access.manages(session, company_id) + ] projects = await _company_projects(company_id) return { "system": await asyncio.to_thread(layers.list_templates, layers.system_dir()), diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 7796b80d3..9bcbfb596 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -54,9 +54,29 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: app.include_router(items_router.router) app.include_router(combos_router.router) app.dependency_overrides[verify_session] = lambda: session + # 문(39-6 · 시스템 관리자만)은 `test_조합_길은_시스템_관리자만` 이 봄 — 여기는 그 안의 층 규칙 + app.dependency_overrides[combos_router._door] = lambda: None return {"client": TestClient(app), "session": session, "storage": storage} +def test_조합_길은_시스템_관리자만(world): + """39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만 · 담을 로직 읽기는 로그인만.""" + world["client"].app.dependency_overrides.pop(combos_router._door) + for role in ("USER", "ADMIN"): + client = _as(world, role) + assert client.get(URL.format("system")).status_code == 403 + assert client.get("/api/m02/combos/all").status_code == 403 + assert client.get("/api/m02/combos/key/UA000001").status_code == 403 + assert _new(client, "personal", _combo("내 것"), 403) is not None + assert client.get("/api/m02/logic-lib/logics").status_code == 200 + world["session"]["is_master"] = True + assert _as(world, "USER").get(URL.format("system")).status_code == 403 # 회사 마스터도 + world["session"]["is_master"] = False + admin = _as(world, "SYSTEM_ADMIN") + assert admin.get(URL.format("system")).status_code == 200 + assert admin.get(URL.format("company")).status_code == 200 + + def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: world["session"].update(role=role, user_id=user) return world["client"] diff --git a/resources/tester/test_m02_layer_access.py b/resources/tester/test_m02_layer_access.py index 42e5189b3..f89e29ea9 100644 --- a/resources/tester/test_m02_layer_access.py +++ b/resources/tester/test_m02_layer_access.py @@ -16,6 +16,7 @@ from fastapi import HTTPException from config import config_system from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers @@ -55,16 +56,16 @@ def test_권한_표(): } assert got == { "일반": ["읽기", "읽기", "403", "수정", "403", "403"], - "회사관리": ["읽기", "수정", "403", "수정", "읽기", "403"], - "마스터": ["읽기", "수정", "403", "수정", "읽기", "403"], - "시스템": ["수정", "수정", "읽기", "수정", "읽기", "읽기"], + "회사관리": ["읽기", "수정", "403", "수정", "수정", "403"], # 맡은 회사(39-6) + "마스터": ["읽기", "수정", "403", "수정", "수정", "403"], + "시스템": ["수정", "수정", "수정", "수정", "수정", "수정"], # 어느 회사나 맡음 } # 자기 층 owner 를 적어도 같음 · 모양 틀림 400 · 회사 없음 409 assert access.grant(USER, "personal", "7/42").write is True assert _answer(SYSADMIN, "personal", "9") == "400" assert _answer({**USER, "company_id": None}, "company", None) == "409" with pytest.raises(HTTPException) as caught: - access.require(ADMIN, "personal", "7/43", write=True) + access.require(USER, "company", None, write=True) assert caught.value.status_code == 403 @@ -89,8 +90,19 @@ def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: monkeypatch.setattr(layers_router, "_company_users", company_users) monkeypatch.setattr(layers_router, "_companies", companies) + notes: list[tuple[int, str, str | None]] = [] + + async def member(owner: str) -> bool: + company, _, user = owner.partition("/") + return users.get(int(user)) == int(company) if user else int(company) in (7, 8, 9) + + async def note(session: dict[str, Any], got: Any) -> None: + notes.append((session["user_id"], got.layer, got.owner)) + + monkeypatch.setattr(items_router, "_member", member) + monkeypatch.setattr(items_router, "_note", note) session: dict[str, Any] = dict(USER) - return {"client": _app(session), "session": session, "storage": storage} + return {"client": _app(session), "session": session, "storage": storage, "notes": notes} def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): @@ -111,14 +123,44 @@ def test_남의_층은_자리가_있을_때만_읽기(world: dict[str, Any]): assert client.get(tree).status_code == 200 assert client.get(table).status_code == 200 # 양식 길도 같은 규칙 body = {"op": "add-folder", "경로": [], "이름": "새", "판": client.get(tree).json()["판"]} - assert client.post(tree, json=body).status_code == 403 # 읽기만 + assert client.post(tree, json=body).status_code == 200 # 맡은 회사 = 고침(39-6) + assert world["notes"] == [(42, "personal", "7/43")] # 남의 층 쓰기 = 시스템 로그 한 줄 assert client.get("/api/m02/items?layer=personal&owner=7/43").status_code == 200 session.update(USER) assert client.get(tree).status_code == 403 assert client.get(table).status_code == 403 + assert ( + client.post(tree, json={**body, "이름": "몰래"}).status_code == 403 + ) # 일반 = 남 개인 막힘 session.update(SYSADMIN) assert client.get(tree).status_code == 200 - assert client.post(tree, json=body).status_code == 403 # 시스템 관리자도 남의 층은 읽기만 + body = {"op": "add-folder", "경로": [], "이름": "AS", "판": client.get(tree).json()["판"]} + assert client.post(tree, json=body).status_code == 200 # 시스템 관리자 = 어느 회사나 + + +def test_남의_층_첫_쓰기는_소속_확인_뒤_뼈대(world: dict[str, Any]): + """39-6 — 폴더 없는 남의 층에 쓰면 주인이 있을 때만 뼈대 · 없는 사람 · 남의 회사 사람은 403 · + 읽기는 여전히 뼈대를 안 만듦 · 일위대가 키는 그 층 머리(UC · UP).""" + client, session, storage = world["client"], world["session"], world["storage"] + session.update(ADMIN) + add = {"op": "add-folder", "경로": [], "이름": "새", "판": ""} + assert client.get("/api/m02/layers/personal/tree?owner=7/44").status_code == 404 + assert not (storage / "7/44").exists() + assert client.post("/api/m02/layers/personal/tree?owner=7/99", json=add).status_code == 403 + assert not (storage / "7/99").exists() # 없는 사람 폴더를 안 만듦 + assert client.post("/api/m02/layers/personal/tree?owner=9/90", json=add).status_code == 403 + first = client.post("/api/m02/layers/personal/tree?owner=7/44", json=add) + assert first.status_code == 409 # 뼈대는 섰고 판이 달라 409 — 다시 읽고 고침 + assert (storage / "7/44/templates/table/구조물집계표.json").is_file() + add["판"] = client.get("/api/m02/layers/personal/tree?owner=7/44").json()["판"] + assert client.post("/api/m02/layers/personal/tree?owner=7/44", json=add).status_code == 200 + session.update(SYSADMIN) + assert client.post("/api/m02/layers/company/tree?owner=5", json=add).status_code == 403 + assert not (storage / "5").exists() # 없는 회사 + add["판"] = "" + assert client.post("/api/m02/layers/company/tree?owner=9", json=add).status_code == 409 + assert (storage / "9/templates/table/구조물집계표.json").is_file() + assert [n[2] for n in world["notes"]] == ["7/44", "7/44", "9"] def test_남의_층_목록(world: dict[str, Any]): @@ -129,14 +171,21 @@ def test_남의_층_목록(world: dict[str, Any]): people = "/api/m02/layers/personal/owners" assert client.get(people).status_code == 403 # 일반 사용자 assert client.get("/api/m02/layers/company/owners").status_code == 403 + (storage / "7/43/templates/table").mkdir(parents=True) + (storage / "7/43/templates/table/구조물집계표.json").write_text("{}", encoding="utf-8") session.update(ADMIN) - assert client.get(people).json() == [ - {"owner": "7/43", "name": "u43"} - ] # 본인 · 44(자리 없음) 뺌 + assert client.get(people).json() == [ # 39-6 — 회사 사람 전부 · 나 맨 위 · 빈 사람 표시 + {"owner": "7/42", "name": "u42", "비어있음": True}, + {"owner": "7/43", "name": "u43", "비어있음": False}, + {"owner": "7/44", "name": "u44", "비어있음": True}, + ] + assert not (storage / "7/44").exists() # 목록이 폴더를 안 만듦 assert client.get(f"{people}?company=9").status_code == 403 # 남의 회사 사람 session.update(SYSADMIN) assert client.get("/api/m02/layers/company/owners").json() == [ {"owner": "7", "name": "c7"}, # 자기 회사는 자리 없어도 {"owner": "9", "name": "c9"}, ] - assert client.get(f"{people}?company=9").json() == [{"owner": "9/90", "name": "u90"}] + assert client.get(f"{people}?company=9").json() == [ + {"owner": "9/90", "name": "u90", "비어있음": True} + ] diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 92c9f1bc6..4724c5ab2 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -13,6 +13,7 @@ from fastapi.testclient import TestClient from common_util import common_util_storage from common_util.common_util_auth import verify_session from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module from M02_MasterTemplete import M02_Template_Layers as layers from resources.tester.helper_m02_master_sample import sample_table @@ -135,15 +136,23 @@ def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company" -def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None: +def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴( + world: dict[str, Any], monkeypatch: pytest.MonkeyPatch +) -> None: client, session = world["client"], world["session"] + notes: list[str] = [] + + async def note(_session: dict[str, Any], got: Any) -> None: + notes.append(got.owner) + + monkeypatch.setattr(items_router, "_note", note) body = {"to": "personal", "종류": "table", "이름": "구조물집계표"} assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200 mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}" assert client.get(mine).status_code == 200 session.update(user_id=43) theirs = mine + "&user_id=42" - # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만 읽기 · 쓰기는 본인만 + # 층 길 = 권한 한 벌(PLAN 27-1c) — 같은 회사 남의 개인 층은 회사 관리자만(맡은 회사 · 39-6) assert client.get(theirs).status_code == 403 session["role"] = "ADMIN" assert client.get(theirs).status_code == 200 @@ -152,8 +161,9 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor f"/api/m02/layers/personal/templates/table/내표?project_id={P1}&user_id=42", json={"판": None, "문서": {"열": []}}, ) - assert put.status_code == 403 - assert not (world["storage"] / "7/42/templates/table/내표.json").exists() + assert put.status_code == 200 # 회사 관리자 = 같은 회사 개인 고침 · 시스템 로그 + assert (world["storage"] / "7/42/templates/table/내표.json").is_file() + assert notes == ["7/42"] session["role"] = "USER" # 첫 진입 때 구조물집계표 빈 뼈대가 서므로(PLAN 21-2) 새 이름으로 씀 put = client.put( @@ -165,7 +175,12 @@ def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(wor got = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} ) - assert got.status_code == 200 + assert got.status_code == 403 # 일반 = 남 개인 못 가져옴(39-6) + session["role"] = "ADMIN" + got = client.post( + f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42} + ) + assert got.status_code == 200 # 회사 관리자 = 같은 회사 개인 가져옴 assert client.get(mine.replace(P1, P_OTHER)).status_code == 403 far = client.post( f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90} @@ -186,6 +201,15 @@ def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str sources = client.get(f"/api/m02/projects/{P1}/sources").json() assert [row["project_id"] for row in sources["project"]] == [P2] assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"} + for user in (42, 43): # 개인 층 둘 — 일반은 내 것만 · 회사 관리자는 회사 사람 전부(39-6) + layers.write_template(layers.personal_dir(7, user), "table", "표", {"열": []}) + assert [row["user_id"] for row in sources_of(client)] == [42] + world["session"]["role"] = "ADMIN" + assert [row["user_id"] for row in sources_of(client)] == [42, 43] + + +def sources_of(client: TestClient) -> list[dict[str, Any]]: + return client.get(f"/api/m02/projects/{P1}/sources").json()["personal"] def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None: From 0e28b5be4abfdc76b457347694a5ec3fa40a00e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:36:51 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=E3=80=8C=EC=8B=A4=EB=AC=B4=EB=AC=B8=EC=84=9C=E3=80=8D=20?= =?UTF-8?q?=E2=80=94=20=EC=98=9B=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=83=81=EC=84=B8=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=ED=86=B5=EC=A7=B8=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0(39-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_Practice.ts — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 - [통째 지우기] = 물음 두 번 → DELETE /api/m02/practice(시스템 관리자 문 is_system_admin · 판 · 쓰는 곳 0 확인 · 줄을 비움) - Combo_Store.drop_practice · 시험 test_m02_practice.py(임시 사본) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Combo_Store.py | 15 ++ .../M02_MasterTemplete_Practice.ts | 224 ++++++++++++++++++ .../M02_MasterTemplete_Router_Combos.py | 9 + .../M02_MasterTemplete_UI_Side.ts | 22 +- .../M02_MasterTemplete_UI_Style.css | 40 ++++ resources/tester/test_m02_practice.py | 59 +++++ .../tester/test_m02_structure_options.py | 13 +- ui_template/ui_template_locale_m2.ts | 26 ++ 8 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Practice.ts create mode 100644 resources/tester/test_m02_practice.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index 52a846549..409fb154c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -289,6 +289,21 @@ def edit(key: str, row: Any, version: str) -> dict[str, Any]: return {"키": key, "판": _save(doc, version), "combo": fixed} +def drop_practice(version: str) -> dict[str, Any]: + """실무문서(마스터 조합 표 = 옛 일위대가 조합 · PLAN 39-2) 통째 지우기 — 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 판 다르면 409 · 마스터 층에서만.""" + if heads()[-1] != MASTER: + raise store.StoreError(400, "실무문서는 마스터 층에만 있음") + doc, have = _load() + if have != (version or ""): + raise store.StoreError(409, {"stale": [FILE], "판": have}) + keys = [str(r.get("키")) for r in doc.get("줄") or []] + _refuse_used(keys) + if keys: + _m01(combo_store._write, [{"op": "delete", "key": k} for k in keys], have) + return {**listing(), "지운조합": keys} + + def delete(key: str, version: str) -> dict[str, Any]: """지우기 — 이 층 구조물 조합이 쓰면 409 + 항목 이름들(지우지 않음).""" if heads()[-1] == MASTER: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Practice.ts b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts new file mode 100644 index 000000000..f3b72ceac --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Practice.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * M02_MasterTemplete_Practice.ts + * 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) — + * 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) · + * [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인). + * 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; +import { + fetchCombo, + fetchComboList, + type ComboList, + type ComboOne, +} from "./M02_MasterTemplete_Labor_Api"; + +export interface PracticeSectionOptions { + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 메인 칸에 화면을 띄움 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; +} + +export interface PracticeSection { + title: string; + body: HTMLElement; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** 서버 까닭 글 — `{errors}` · 판 다름 · 글 */ +function reasonOf(detail: unknown, status: number): string { + if (typeof detail === "string") return detail; + const d = detail as { errors?: string[]; stale?: unknown } | null; + if (d?.errors?.length) return d.errors.join(" · "); + return d?.stale ? L("M02_PracticeStale") : `HTTP ${status}`; +} + +/** 통째 지우기 — 줄을 모두 비움(판 = 읽은 표 판) */ +async function dropPractice(판: string): Promise { + const res = await fetch(`${API_BASE_URL}/m02/practice?판=${encodeURIComponent(판)}`, { + method: "DELETE", + credentials: "include", + }); + if (res.ok) return; + const data = (await res.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(reasonOf(data.detail, res.status)); +} + +const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-practice__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +const cells = (values: string[]): HTMLElement[] => + values.map((v) => el("td", { text: v, attrs: { title: v } })); + +export function buildPracticeSection(opt: PracticeSectionOptions): PracticeSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: L("M02_PracticeRow"), + attrs: { type: "button", "data-practice-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + const view = el("div", { className: "m02-practice" }); + let opened = false; + let nav = 0; // 늦게 온 응답 버림 + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + row.addEventListener("click", () => void open()); + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + opt.show(L("M02_PracticeTitle"), view); + opt.onOpen(); + mark(true); + await drawList(); + } + + async function drawList(): Promise { + const my = ++nav; + const stop = showLoading(view); + let list: ComboList; + try { + list = await fetchComboList(); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawList())); + return; + } finally { + stop(); + } + if (my !== nav) return; + const drop = createButton({ label: L("M02_PracticeDrop"), variant: "danger", size: "sm" }); + drop.disabled = !list.줄.length; + drop.addEventListener("click", () => void dropAll(list, drop)); + const rows = list.줄.map((c) => { + const tr = el("tr", { + className: "m02-practice__row", + attrs: { "data-practice-key": c.키 }, + children: cells([c.키, c.이름, c.구분 ?? "", c.단위 ?? "", String(c.count)]), + }); + tr.addEventListener("click", () => void drawOne(c.키)); + return tr; + }); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [ + el("span", { + className: "m02-practice__note", + text: L("M02_PracticeCount").replace("{n}", String(list.줄.length)), + }), + drop, + ], + }), + rows.length + ? table( + [ + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeGroup"), + L("M02_PracticeUnit"), + L("M02_PracticeLogics"), + ], + rows, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeEmpty") }), + ); + } + + /** 상세 — 읽기만(이름 · 키 · 구분 · 단위 · 비고 + 담은 로직 표) */ + async function drawOne(key: string): Promise { + const my = ++nav; + const stop = showLoading(view); + let one: ComboOne; + try { + one = await fetchCombo(key); + } catch (error) { + stop(); + if (my === nav) view.replaceChildren(loadingError(why(error), () => void drawOne(key))); + return; + } finally { + stop(); + } + if (my !== nav) return; + const back = createButton({ label: L("M02_PracticeBack"), variant: "ghost", size: "sm" }); + back.addEventListener("click", () => void drawList()); + const c = one.combo; + const info = [ + [L("M02_PracticeKey"), c.키], + [L("M02_PracticeGroup"), c.구분 ?? ""], + [L("M02_PracticeUnit"), c.단위 ?? ""], + ].map(([k, v]) => el("span", { className: "m02-practice__note", text: `${k} ${v}` })); + const lines = one.줄.map((x) => + el("tr", { + children: cells([ + String(x.차례), + x.로직, + x.ok ? (x.이름 ?? "") : (x.까닭 ?? ""), + x.결과단위 ?? "", + x.메모 ?? "", + ]), + }), + ); + view.replaceChildren( + el("div", { + className: "m02-practice__bar", + children: [back, el("strong", { text: c.이름 }), ...info], + }), + ...(c.비고 ? [el("p", { className: "m02-practice__memo", text: c.비고 })] : []), + lines.length + ? table( + [ + L("M02_PracticeOrder"), + L("M02_PracticeKey"), + L("M02_Name"), + L("M02_PracticeUnit"), + L("M02_PracticeMemo"), + ], + lines, + ) + : el("p", { className: "m02-practice__note", text: L("M02_PracticeNoLogic") }), + ); + } + + /** 통째 지우기 — 물음 두 번 · 서버가 쓰는 곳 · 판을 다시 봄 */ + async function dropAll(list: ComboList, button: HTMLButtonElement): Promise { + const n = String(list.줄.length); + if (!(await showConfirmDialog(L("M02_PracticeDropAsk").replace("{n}", n), L("M02_Delete")))) + return; + if (!(await showConfirmDialog(L("M02_PracticeDropAsk2"), L("M02_PracticeDrop")))) return; + button.disabled = true; + try { + await dropPractice(list.판); + showToast(L("M02_PracticeDropped").replace("{n}", n), "success"); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + await drawList(); + } + + return { title: L("M02_PracticeTitle"), body, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 71232ea4b..ad8fb8ad4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -157,6 +157,15 @@ def delete_combo( return _call(_root(session, layer, owner, write=True), combos.delete, key, 판) +@router.delete("/practice") +def delete_practice(판: str = "", session: Session = Depends(verify_session)) -> dict: + """실무문서 통째 지우기(PLAN 39-2) — 시스템 관리자만 · 마스터 조합 표 줄을 모두 비움 · + 구조물이 쓰는 조합이 있으면 409.""" + if not access.is_system_admin(session): + raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자만") + return _call(_root(session, "system", None, write=True), combos.drop_practice, 판) + + @router.post("/layers/{layer}/combos/tree") def post_tree( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 691426e65..7d10e1875 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -41,6 +41,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; +import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -147,7 +148,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), (allPicked = null), paint()), + onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -159,7 +160,7 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), (allPicked = null), paint()), + onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint()), }); /** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */ const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => { @@ -191,9 +192,22 @@ export function buildSide(opt: SideOptions): SideHandle { 도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), } : null; + // 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 + const practice = + layer === "system" && opt.isAdmin + ? buildPracticeSection({ + confirmLeave: opt.confirmLeave, + show: opt.showView, + onOpen: () => (matset.clear(), options.clear(), (allPicked = null), paint()), + }) + : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], + children: [ + summary, + ...(extras ? [extras.옵션, extras.도면] : []), + ...(practice ? [section(practice.title, practice.body)] : []), + ], }); attachCollapsible(listHost); @@ -285,12 +299,14 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); + if (opt.getOpen()) practice?.clear(); } async function selectAll(kind: AllKind): Promise { if (!opt.onAll || allPicked === kind || !(await opt.confirmLeave())) return; matset.clear(); options.clear(); + practice?.clear(); allPicked = kind; opt.onAll(kind); // 메인 칸 보기 화면 — 연 뒤 `paint` 가 이 줄을 고른 채 그림 paint(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index aa14b148e..0fd456c41 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -22,6 +22,46 @@ border-top: 1px solid var(--color-border); } +/* 실무문서 화면(PLAN 39-2) — 위 막대(개수 · 통째 지우기 / 목록으로 · 이름 · 정보) + 표 */ +.m02-practice { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-practice__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-practice__bar > button:last-child:not(:first-child) { + margin-left: auto; +} + +.m02-practice__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-practice__memo { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + +.m02-practice__row { + cursor: pointer; +} + +.m02-practice__row:hover { + background: var(--color-mist-violet); +} + .m02-side__row { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_m02_practice.py b/resources/tester/test_m02_practice.py new file mode 100644 index 000000000..4466a0ac5 --- /dev/null +++ b/resources/tester/test_m02_practice.py @@ -0,0 +1,59 @@ +"""실무문서(PLAN 39-2) — 옛 마스터 일위대가 조합 표 통째 · 시스템 관리자만 통째 지우기. + +못박는 것 + - `DELETE /api/m02/practice?판=` = 시스템 관리자만(그 밖 403) · 판 다르면 409 · + 구조물이 쓰는 조합이 있으면 409(아무것도 안 지움) · 지우면 줄 0 · 마스터 틀 검사 그대로. + - 화면(`Practice.ts`) = 마스터 층 · 시스템 관리자만 · 물음 두 번 · 상세 읽기만. +정본(마스터)은 읽기만 · 쓰기는 임시 사본. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from resources.tester.test_m02_combo_layers import _as, _list, master_copy, world # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" + + +def _drop(client, 판: str, want: int) -> dict: + res = client.delete("/api/m02/practice", params={"판": 판}) + assert res.status_code == want, res.text + return res.json() + + +def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_copy, monkeypatch): # noqa: F811 + admin = _as(world, "SYSTEM_ADMIN") + before = _list(admin, "system") + assert before["줄"] # 사본에 옛 조합이 있음 + for role in ("USER", "ADMIN"): + _drop(_as(world, role), before["판"], 403) + admin = _as(world, "SYSTEM_ADMIN") + _drop(admin, "옛판", 409) + used = before["줄"][0]["키"] + monkeypatch.setattr(combo_store, "combo_users", lambda k: ["항목 하나"] if k == used else []) + got = _drop(admin, before["판"], 409) + assert "항목 하나" in json.dumps(got, ensure_ascii=False) + assert len(_list(admin, "system")["줄"]) == len(before["줄"]) # 아무것도 안 지움 + monkeypatch.setattr(combo_store, "combo_users", lambda k: []) + got = _drop(admin, before["판"], 200) + assert got["줄"] == [] and len(got["지운조합"]) == len(before["줄"]) + doc = json.loads((master_copy / combos.FILE).read_text(encoding="utf-8")) + assert doc["줄"] == [] and m01.cm.check_form(combos.FILE, doc) == [] + again = _list(admin, "system") + assert _drop(admin, again["판"], 200)["지운조합"] == [] # 빈 표 = 그대로 + + +def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만(): + side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8") + assert 'layer === "system" && opt.isAdmin' in side + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래 + view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8") + assert view.count("showConfirmDialog(") == 2 + assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음 diff --git a/resources/tester/test_m02_structure_options.py b/resources/tester/test_m02_structure_options.py index 5d1a98e5e..c8f27428d 100644 --- a/resources/tester/test_m02_structure_options.py +++ b/resources/tester/test_m02_structure_options.py @@ -120,11 +120,14 @@ def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에 assert side.index("옵션: section(options.title") < side.index( '도면: section(L("M02_KindDrawing")' ) - # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 - assert "children: [summary, ...(extras ? [extras.옵션, extras.도면" in side - # 자재 조합을 열면 이 줄 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · 「전체 항목」 고름 지움 - assert "onOpen: () => (options.clear(), (allPicked = null), paint())" in side - assert "onOpen: () => (matset.clear(), (allPicked = null), paint())" in side + # 차례 = 구조물 조합(일위대가는 그 하위 · 39-2) → 옵션 → 도면 양식 → 실무문서 + order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0] + assert ( + order.index("summary") < order.index("extras.옵션, extras.도면") < order.index("practice") + ) + # 자재 조합을 열면 이 줄 · 실무문서 · 「전체 항목」 고름 지움 / 이 줄을 열면 자재 조합 · … 지움 + assert "onOpen: () => (options.clear(), practice?.clear(), (allPicked = null), paint())" in side + assert "onOpen: () => (matset.clear(), practice?.clear(), (allPicked = null), paint())" in side paint = side.split("function paint(): void {")[1].split("\n }\n")[0] assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움 api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 1058adaa6..737e9f4d2 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -174,4 +174,30 @@ export const ui_locales_m2 = { M02_TreeDone: ["나무를 고침", "Tree updated"], M02_KindLabor: ["일위대가", "Unit costs"], M02_ItemsAll: ["전체 항목", "All items"], + M02_PracticeTitle: ["실무문서", "Practice documents"], + M02_PracticeRow: ["실무문서 목록", "Practice document list"], + M02_PracticeCount: [ + "옛 마스터 일위대가 조합 {n}개 — 보기 · 복사 원본만(고칠 수 없음)", + "{n} old master unit-cost combos — view and copy source only", + ], + M02_PracticeEmpty: ["실무문서 없음", "No practice documents"], + M02_PracticeKey: ["키", "Key"], + M02_PracticeGroup: ["구분", "Group"], + M02_PracticeUnit: ["단위", "Unit"], + M02_PracticeLogics: ["로직 수", "Logics"], + M02_PracticeOrder: ["차례", "No."], + M02_PracticeMemo: ["메모", "Memo"], + M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeBack: ["목록으로", "Back to list"], + M02_PracticeDrop: ["통째 지우기", "Delete all"], + M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], + M02_PracticeDropAsk2: [ + "되돌릴 수 없음 — 정말 통째로 지울까요?", + "This cannot be undone — really delete all?", + ], + M02_PracticeDropped: ["실무문서 {n}개를 지움", "Deleted {n} practice documents"], + M02_PracticeStale: [ + "그 사이 실무문서가 바뀜 — 목록을 다시 받음", + "Practice documents changed — reloaded", + ], } as const; From 364d22ff6fac4a9f09e647f54bf618943bcdb9b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:37:46 +0900 Subject: [PATCH 14/16] =?UTF-8?q?feat(M02):=20=EC=83=88=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EB=91=90=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20=E3=80=8C=EC=83=88=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=EC=83=81=EC=84=B8=20=EC=B9=B8=20[?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0]=20[=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EC=83=9D=EC=84=B1]=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=3D=20=ED=83=AD=20+=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=E2=86=92=20=EC=A7=80=EA=B8=88=20=EC=B8=B5=20=C2=B7?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B8=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=83=88?= =?UTF-8?q?=20=ED=82=A4=20=C2=B7=20=EC=83=88=20=EB=B2=88=ED=98=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 H4 — 나무 copyIn(붙여넣기 길 · 새 열 하나면 그 열 고름) · folder 손잡이 · ItemPick buildTwoWay(빈 카드도 씀). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.css | 20 +++++++ .../M02_MasterTemplete_ItemPick.ts | 52 ++++++++++++++++++- .../M02_MasterTemplete_TreePanel.ts | 42 +++++++++++++-- .../M02_MasterTemplete_UI_Page.ts | 29 ++++++++++- 4 files changed, 135 insertions(+), 8 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index 70f57c665..a6713f59b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -13,3 +13,23 @@ .ui-modal.m02-itempick__dialog { width: min(960px, 94vw); } + +/* 두 단추 화면 — 새 구조물 조합 · 빈 카드 */ +.m02-itempick__two { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.m02-itempick__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-itempick__buttons { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index dc1fbc122..54cc67a51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -7,7 +7,7 @@ * 다른 사람 = 사람 고르기 하나(`fetchOwners`) · 실무문서 = 마스터 일위대가 조합 줄(일위대가 고를 때만). * ========================================================================== */ -import { createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showConfirmDialog } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, type Layer } from "./M02_MasterTemplete_Api_Fetch"; @@ -30,6 +30,8 @@ const TEXT = { person: "사람", noPeople: "다른 사람이 없음", failed: "목록을 못 받음 — {value}", + makeNew: "새로 만들기", + makeCopy: "복사 생성", }; /** 고른 곳 — `practice` = 실무문서(마스터 일위대가 조합) */ @@ -146,6 +148,54 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { }; } +export interface TwoWayOptions { + /** 위 안내 한 줄 */ + note: string; + /** [새로 만들기] — 지금 길(창 · 빈 문서) */ + onNew: () => void; + /** [복사 생성] 뒤 고를 목록 */ + picker: Omit; + /** 줄 누름 → 확인 글(취소면 그대로 고름) */ + ask: (picked: Picked) => string; + /** 확인 뒤 — 복사(실패 알림은 부르는 쪽) */ + onCopy: (picked: Picked) => Promise; +} + +/** [새로 만들기] [복사 생성] 두 단추(39-5 · 답 2) — 복사 생성을 누르면 같은 칸 아래 탭 + 목록 */ +export function buildTwoWay(opt: TwoWayOptions): HTMLElement { + const fresh = createButton({ label: TEXT.makeNew, variant: "filled" }); + const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); + const below = el("div", { className: "m02-itempick__below" }); + let busy = false; + fresh.addEventListener("click", opt.onNew); + copy.addEventListener("click", () => { + if (below.firstChild) return; + copy.classList.add("is-active"); + const picker = buildPicker({ + ...opt.picker, + onPick: (picked) => + void (async () => { + if (busy || !(await showConfirmDialog(opt.ask(picked)))) return; + busy = true; // 연달아 눌러도 한 번만 + try { + await opt.onCopy(picked); + } finally { + busy = false; + } + })(), + }); + below.append(picker.root); + }); + return el("div", { + className: "m02-itempick__two", + children: [ + el("p", { className: "m02-itempick__note", text: opt.note }), + el("div", { className: "m02-itempick__buttons", children: [fresh, copy] }), + below, + ], + }); +} + /** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( opt: Omit & { title: string; ask?: (picked: Picked) => string }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..bb070c057 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -107,6 +107,14 @@ export interface TreePanelHandle { select: (colId: string | null) => void; /** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */ newItem: () => void; + /** 고른 폴더(하나) — 없으면 맨 위(`[]`) · 새 항목 · 복사 생성이 들어갈 자리 */ + folder: () => string[]; + /** 복사 생성(39-5) — 다른 곳(`from`) 항목 키 하나를 이 층 `dest` 에 새 키로 붙이고 그 열을 고름 */ + copyIn: ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ) => Promise; /** 표 머리에서 온 나무 고침(이름 바꾸기 · PLAN 31-1) — 메뉴 고침과 같은 길(저장 물음 · 막힘 안내 · 다시 그림) */ editFromTable: (body: TreeEdit, colId: string | null) => Promise; } @@ -417,8 +425,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 const gone = note?.지운항목 ?? []; const made = note?.열; - const col = - typeof made === "string" ? made : keepCol && !gone.includes(keepCol) ? keepCol : null; + // 붙여넣기로 새 열이 하나면(복사 생성) 그 열 + const one = typeof made === "string" ? made : made?.length === 1 ? made[0] : null; + const col = one ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); activeCol = col; const parent = note?.경로 ? note.경로.slice(0, -1) : []; parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); @@ -643,13 +652,36 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { }); /** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */ - const newItem = (): void => { + const folder = (): string[] => { const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined; - void addItem(at?.kind === "folder" ? at.path : []); + return at?.kind === "folder" ? at.path : []; + }; + const newItem = (): void => void addItem(folder()); + + const copyIn = async ( + from: { layer: Layer; owner: string | null }, + key: string, + dest: string[], + ): Promise => { + const run = api.paste; + if (!run || !opt.canEdit || !(await ready())) return; + await apply(() => + run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + ); }; const editFromTable = async (body: TreeEdit, colId: string | null): Promise => { if (opt.canEdit && (await ready())) await edit(body, colId); }; - return { root, search: search.root, add: tools, refresh, select, newItem, editFromTable }; + return { + root, + search: search.root, + add: tools, + refresh, + select, + newItem, + folder, + copyIn, + editFromTable, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..4f4d2a852 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -43,8 +43,13 @@ import { import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side"; import { buildAllItems } from "./M02_MasterTemplete_ItemList"; +import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; +const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안"; +const NEW_ASK = + "{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름"; + /** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */ const ALL_TITLE = { structure: "M02_KindStructure", @@ -130,8 +135,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise tree.newItem()) } : {}), + // 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5) + ...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => openNew()) } : {}), onColumn: (column) => { readOnlyBar?.setColumn(column); tree.select(column); @@ -145,6 +150,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { + const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop"); + main.showView( + L("M02_TreeNewItem"), + buildTwoWay({ + note: NEW_NOTE.replace("{value}", where()), + onNew: () => tree.newItem(), + picker: { user, company, kind: "item" }, + ask: (picked) => + NEW_ASK.replace("{name}", pickedName(picked)) + .replace("{from}", picked.from.label) + .replace("{to}", where()), + onCopy: async (picked) => { + if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder()); + }, + }), + ); + }; // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { layer, From a2d14719319b8672d998c0c61a2d93d65bedc7c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:39:38 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(M02):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=A7=A1=EC=9D=80=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20accessFor=20=EC=A7=9D=20=C2=B7=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=3D=20=ED=9A=8C=EC=82=AC=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=20=EB=82=98=EB=AC=B4(=EB=82=98=20=EB=A7=A8=20?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=EB=B9=84=EC=96=B4=20=EC=9E=88=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=E3=80=87=E3=80=87=EB=A1=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=E2=80=A6=E3=80=8D=20=EB=B3=BC=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=C2=B7=20=EB=8C=80=EC=83=81=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD(39-6=20E5~E?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../M02_MasterTemplete_Api_Fetch.ts | 4 ++- .../M02_MasterTemplete_Layer.ts | 13 ++++++--- .../M02_MasterTemplete_TreePanel.ts | 23 ++++++++------- .../M02_MasterTemplete_TreePanel_CopyTo.ts | 26 +++++++++++++++++ .../M02_MasterTemplete_TreePanel_Roots.ts | 14 ++++++---- .../M02_MasterTemplete_UI_Page.ts | 9 ++++-- resources/tester/test_m02_layer_screen.py | 28 ++++++++++++++----- ui_template/ui_template_locale_m2.ts | 9 +++--- 8 files changed, 91 insertions(+), 35 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 0ef688765..6ac97e28e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -510,9 +510,11 @@ export const pasteTree = ( export interface LayerOwner { owner: string; name: string; + /** 개인 = 집계표가 아직 없음(사람 나무 「비어 있음」 · 39-6) */ + 비어있음?: boolean; } -/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사 사람(본인 뺌 · 층 자리 있는 것만) */ +/** 회사 = 시스템 관리자만 회사 목록 · 개인 = 그 회사를 맡은 사람 · 회사 사람 전부(나 맨 위) */ export const fetchOwners = ( layer: "company" | "personal", company: string | null = null, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 3b94c655c..c1860e584 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -79,7 +79,8 @@ export interface LayerAccess { needCompany: boolean; } -/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`)은 늘 읽기만(읽을 수 있나는 서버가 가름) */ +/** 서버 `M02_Layer_Access.grant` 와 같은 규칙 — 남의 층(`owner`) = 그 회사를 맡았으면 고침(39-6) · + * 읽을 수 있나는 서버가 가름 */ export function accessFor( layer: ManageLayer, owner: string | null, @@ -87,10 +88,14 @@ export function accessFor( ): LayerAccess { if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false }; const own = ownOwner(layer, user); - if (owner && owner !== own) return { canWrite: false, needCompany: false }; + if (owner && owner !== own) { + return { canWrite: manages(owner.split("/")[0], user), needCompany: false }; + } if (!own) return { canWrite: false, needCompany: true }; - const companyAdmin = user?.role === "ADMIN" || user?.role === "SYSTEM_ADMIN" || !!user?.is_master; - return { canWrite: layer === "personal" || companyAdmin, needCompany: false }; + return { + canWrite: layer === "personal" || manages(ownOwner("company", user), user), + needCompany: false, + }; } /** 회사 C 를 맡음 — 시스템 관리자 · 또는 C 의 회사 관리자(서버 `M02_Layer_Access.manages` 짝 · 39-6) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 014e9c9b1..13c61f1db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -35,6 +35,7 @@ import { type TreeRef, type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; +import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { @@ -83,8 +84,8 @@ export interface TreePanelOptions { }; /** 항목 추가 창의 일위대가 고르기(PLAN 31-2 · 세 층 조합 · 찾기 AND) — 없으면 그 줄 없음 */ pickCombo?: () => Promise<{ key: string; label: string } | null>; - /** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */ - copyTo?: { layer: Layer; owner: string | null; label: string }[]; + /** 「〇〇로 복사…」 — 그 층 폴더를 골라 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로 · 39-6) */ + copyTo?: CopyTarget[]; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; /** 항목을 누름 — 표에서 그 열을 고름 */ @@ -533,18 +534,20 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); } - /** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */ - async function copyTo( - list: Target[], - to: { layer: Layer; owner: string | null; label: string }, - ): Promise { + /** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */ + async function copyTo(list: Target[], to: CopyTarget): Promise { if (!api.paste || !list.length) return; const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) }; try { - await api.paste(to.layer, to.owner, clip, []); - const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, ""); + const dest = await askCopyFolder(api.fetch, to, list.length); + if (!dest) return; + await api.paste(to.layer, to.owner, clip, dest); + const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, ""); showToast( - L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where), + L("M02_TreeCopiedTo") + .replace("{n}", String(list.length)) + .replace("{layer}", where) + .replace("{folder}", dest.join(" / ") || L("M02_TreeTop")), "success", ); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts new file mode 100644 index 000000000..38b03c5f6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -0,0 +1,26 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_CopyTo.ts + * 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null. + * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. + * ========================================================================== */ + +import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface CopyTarget { + layer: Layer; + owner: string | null; + /** 메뉴 글 — `{n}` = 개수 */ + label: string; +} + +export async function askCopyFolder( + fetchTree: (layer: Layer, owner: string | null) => Promise, + to: CopyTarget, + n: number, +): Promise { + const view = await fetchTree(to.layer, to.owner).catch(() => null); + const folders = view ? listFolders(buildFolders(view)) : []; + return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index bd748d131..4c4ba094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,8 +4,8 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 - * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 + * 볼 회사를 맡으면(`manages` · 39-6 — 시스템 관리자 · 그 회사 관리자) 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 나 맨 위 · 사람 줄 → 그 사람 라이브러리 · 고침) · 일반 = 내 개인 그대로 * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ @@ -18,6 +18,7 @@ import { accessFor, buildNeedCompany, layerOfRoot, + manages, ownOwner, type Root, } from "./M02_MasterTemplete_Layer"; @@ -40,9 +41,9 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; -/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +/** 볼 회사를 맡음(39-6) — 「개인」 단추 = 그 회사 사람 나무 */ export const seesOthers = (company: string | null, user: SessionUser | null): boolean => - user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + manages(company, user); export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; @@ -57,7 +58,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { } function button(root: Root, owner: string | null, people = false): HTMLButtonElement { - const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; + const readOnly = !people && !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -113,7 +114,8 @@ export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { box.replaceChildren( ...rows.flatMap((one) => { const current = one.owner === opt.active; - const node = { id: one.owner, label: one.name, count: null, folder: true }; + const label = one.비어있음 ? `${one.name} · ${L("M02_PeopleEmpty")}` : one.name; + const node = { id: one.owner, label, count: null, folder: true }; const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { if (!current) return opt.go(one.owner); wrap.hidden = !wrap.hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 9b792a9ce..63227afb3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -76,12 +76,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { const to = layerOfRoot(one); - if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return []; + const toOwner = one === "company" ? companyOwner(company, user) : null; + if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return []; const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal"; - return [{ layer: to, owner: null, label: L(`M02_TreeCopyTo${key}`) }]; + return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }]; }); const tree = createTreePanel({ layer, diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 6ffa334e9..721a75faa 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -26,11 +26,12 @@ def _text(path: Path) -> str: @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_층별_고칠_권한(): - """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c) — 남의 층(owner)은 읽기만.""" + """서버 `M02_Layer_Access` 와 같은 규칙(PLAN 27-1c · 39-6) — 남의 층(owner) = 맡은 회사면 고침.""" text = _text(LAYER) own = re.search(r"^export const ownOwner[\s\S]*?;\n", text, re.M) found = re.search(r"^export function accessFor[\s\S]*?^}", text, re.M) - assert own and found, "ownOwner · accessFor 를 못 찾음" + manages = re.search(r"^export function manages[\s\S]*?^}", text, re.M) + assert own and found and manages, "ownOwner · accessFor · manages 를 못 찾음" script = ( 'type ManageLayer = "system" | "company" | "personal";\n' "interface SessionUser { id: string; role?: string; company_id?: number | null;" @@ -38,6 +39,8 @@ def test_층별_고칠_권한(): "interface LayerAccess { canWrite: boolean; needCompany: boolean }\n" + own.group(0).replace("export ", "") + found.group(0).replace("export ", "") + + "\n" + + manages.group(0).replace("export ", "") + "\nconst cases = JSON.parse(process.argv[1]);" + "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1], c[2]))));" ) @@ -54,7 +57,8 @@ def test_층별_고칠_권한(): ["personal", None, user], ["personal", None, admin], ["personal", None, orphan], ["personal", None, sysadmin], ["personal", "3/7", user], ["personal", "3/7", admin], ["company", "9", sysadmin], - ["personal", "9/90", sysadmin], + ["personal", "9/90", sysadmin], ["personal", "3/5", user], ["personal", "9/90", admin], + ["company", "9", admin], ] # fmt: skip result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -69,7 +73,8 @@ def test_층별_고칠_권한(): ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만 ok, ok, ro, no_company, ok, # 회사 = ADMIN · is_master · SYSTEM_ADMIN · 일반 읽기만 ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내 - ok, ro, ro, ro, # 자기 층 owner 는 자기 층 · 남의 층은 읽기만 + ok, ok, ok, ok, # 자기 층 owner 는 자기 층 · 맡은 회사(39-6)의 남 층 = 고침 + ro, ro, ro, # 일반의 남 개인 · 회사 관리자의 남의 회사 = 못 고침(읽기는 서버가 403) ] # fmt: skip @@ -181,7 +186,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): assert 'el("select"' not in roots and "createSelect" not in roots # 사람 고르기 없앰 assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무 assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기 - assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots + assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6) + assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침 + assert 'L("M02_PeopleEmpty")' in roots assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page assert 'peopleTree("구조물조합", tree.root)' in page @@ -269,12 +276,19 @@ def test_읽기만_표는_단추_고치기_모두_막음(): def test_켜진_층_단추는_목록으로_복사_끌기(): """30-4 — 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 단추 벌은 39-2 에서 지움) · - 30-2 「〇〇로 복사」 = 쓸 수 있는 자기 층(지금 층 뺌) · 끌어다 옮기기는 쓸 수 있는 층만.""" + 30-2 · 39-6 「〇〇로 복사…」 = 쓸 수 있는 층(지금 층 뺌 · 폴더 고름) · 끌어다 옮기기는 쓸 수 있는 층만.""" roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page - assert "if (!accessFor(to, null, user).canWrite || (to === layer && !owner)) return [];" in page + # 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창 + assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page + assert "(to === layer && toOwner === here)) return [];" in page + copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts") + assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to + assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text( + M02 / "M02_MasterTemplete_TreePanel.ts" + ) panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit) drag.arm(button, one.id);" in panel drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c0c5e4e46..be85441fb 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -105,6 +105,7 @@ export const ui_locales_m2 = { M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], M02_SourcePeople: ["다른 사람", "Others"], M02_SourcePractice: ["실무문서", "Practice documents"], + M02_PeopleEmpty: ["비어 있음", "empty"], M02_TreeSearch: [ "찾기 — 이름 · 키 · 폴더 · 단위 (낱말 여럿 = 모두 담은 것)", "Find — name, key, folder, unit (all words)", @@ -144,10 +145,10 @@ export const ui_locales_m2 = { 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})"], - M02_TreeCopiedTo: ["{n}개를 {layer} 맨 위에 복사함", "Copied {n} to the top of {layer}"], + M02_TreeCopyToMaster: ["마스터로 복사… ({n}개)", "Copy to master… ({n})"], + M02_TreeCopyToCompany: ["회사로 복사… ({n}개)", "Copy to company… ({n})"], + M02_TreeCopyToPersonal: ["개인으로 복사… ({n}개)", "Copy to personal… ({n})"], + M02_TreeCopiedTo: ["{n}개를 {layer} 「{folder}」에 복사함", "Copied {n} to {layer} “{folder}”"], M02_TreeFolderName: ["폴더 이름", "Folder name"], M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], From 8f5a573ebcf7613ac9224e120a4651d92ecb7212 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:40:31 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(M02):=20=EA=B0=80=EC=A0=B8=EC=98=AC?= =?UTF-8?q?=20=EA=B3=B3=20=E3=80=8C=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EB=82=98=EB=8A=94=20=EB=BA=8C(?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=ED=83=AD)=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=EC=82=AC=EB=9E=8C=20=E3=80=8C=EB=B9=84=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=20=C2=B7=20=EC=B2=98=EC=9D=8C=20=3D=20?= =?UTF-8?q?=EC=B2=AB=20=EB=B9=88=20=EC=95=84=EB=8B=8C=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=20=C2=B7=20=EB=B9=88=20=EC=82=AC=EB=9E=8C=EC=9D=80=20=EB=B9=88?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-5 — 사람 목록 전부(39-6 E1~E4)에 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemPick.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 54cc67a51..3ee87e241 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -20,7 +20,7 @@ import { type ListKind, } from "./M02_MasterTemplete_ItemList"; import { fetchCombos, type ComboSummary } from "./M02_MasterTemplete_Labor_Api"; -import { readableSources, type Source } from "./M02_MasterTemplete_Layer"; +import { ownOwner, readableSources, type Source } from "./M02_MasterTemplete_Layer"; import { leafName } from "./M02_MasterTemplete_TreePanel_Model"; import "./M02_MasterTemplete_TreePanel.css"; import "./M02_MasterTemplete_ItemPick.css"; @@ -29,6 +29,7 @@ const TEXT = { close: "닫기", person: "사람", noPeople: "다른 사람이 없음", + empty: "비어 있음", failed: "목록을 못 받음 — {value}", makeNew: "새로 만들기", makeCopy: "복사 생성", @@ -58,6 +59,8 @@ const PRACTICE_COLS: Col[] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +/** 층 폴더가 아직 없는 사람(서버 사람 목록 `비어있음` · 39-6) */ +const empty = (o: { owner: string }): boolean => !!(o as { 비어있음?: boolean }).비어있음; export interface PickerOptions { user: SessionUser | null; @@ -106,17 +109,24 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { let owner = tab.owner; let label = tab.label; if (tab.id === "people") { - owners ??= await fetchOwners("personal", opt.company); + // 나는 「개인」 탭 — 뺌 · 빈 사람은 「비어 있음」 · 처음 = 첫 빈 아닌 사람 + const me = ownOwner("personal", opt.user); + owners ??= (await fetchOwners("personal", opt.company)).filter((o) => o.owner !== me); if (mine !== seq) return; if (!owners.length) return list.message(TEXT.noPeople); if (!people.select.value) { people.setOptions( - owners.map((o) => ({ value: o.owner, text: o.name })), - owners[0].owner, + owners.map((o) => ({ + value: o.owner, + text: empty(o) ? `${o.name} · ${TEXT.empty}` : o.name, + })), + (owners.find((o) => !empty(o)) ?? owners[0]).owner, ); } owner = people.select.value; - label = `${tab.label} · ${owners.find((o) => o.owner === owner)?.name ?? owner}`; + const who = owners.find((o) => o.owner === owner); + label = `${tab.label} · ${who?.name ?? owner}`; + if (who && empty(who)) return list.show([], ITEM_COLS[opt.kind], () => {}); // 층 폴더 없음 } const rows = await loadItems(tab.layer, owner); if (mine !== seq) return;