From 26eac086a8e0aef3e31d588755e0f1b8d3c58f4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 08:15:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B3=B5=EC=82=AC=EB=B3=B8=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=C2=B7=20[=EC=9E=AC=EC=A0=81=EC=9A=A9]=20=C2=B7=20[=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20=EC=A0=80=EC=9E=A5]=20=C2=B7=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=83=88=20=ED=95=AD=EB=AA=A9=20=C2=B7=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20(PLAN=2026-3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로 - [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시 - 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄 - B05 구조물 폼이 구조물 id 를 모달에 넘김 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../B05_Profile_UI_Structures_Panel.ts | 5 + .../tester/helper_item_picker_project.cjs | 80 ++++++-- resources/tester/test_item_picker_detail.py | 68 +++++-- resources/tester/ui/item-picker-data.test.ts | 41 +++- ui_template/ui_template_item_picker.css | 27 +++ ui_template/ui_template_item_picker.ts | 89 +++++++- ui_template/ui_template_item_picker_ask.ts | 97 +++++++++ ui_template/ui_template_item_picker_detail.ts | 190 ++++++++++++++---- ui_template/ui_template_item_picker_edit.ts | 6 +- .../ui_template_item_picker_project.ts | 102 +++++++--- 10 files changed, 594 insertions(+), 111 deletions(-) create mode 100644 ui_template/ui_template_item_picker_ask.ts diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index d38f48ae..357b6e80 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field } from "./B05_Profile_UI_Structures_Fields"; import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; import { openItemPicker } from "@ui/ui_template_item_picker"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -501,6 +502,10 @@ export function createStructuresSection( current: summary.get(), canCopy: true, projectId: sectionOptions.projectId ?? null, + // 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만) + structureId: + editingId ?? + (selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null), }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 25343dbf..55ad7a21 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -75,8 +75,26 @@ globalThis.fetch = async (url, init = {}) => { const [, , folderEnc, partEnc] = m; if (!folderEnc && method === "GET") return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) }); + if (folderEnc === "new" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const item = { + layer: "personal", + owner: "1/3", + key: "SS900001", + name: [...body.경로, body.이름].join(" "), + }; + return reply(200, { + key: "SS900001", + 이름: item.name, + 폴더: body.structure, + summary_item: item, + }); + } if (!folderEnc && method === "POST") { - const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`; + if (!body.structure) + return reply(400, { detail: "미리 가져올 구조물(structure)이 필요합니다." }); + const folder = body.structure.replace("#", "n"); state.folders[folder] = { info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, @@ -86,9 +104,24 @@ globalThis.fetch = async (url, init = {}) => { const item = state.folders[decodeURIComponent(folderEnc)]; if (!item) return reply(404, { detail: "복사본이 없음" }); const part = partEnc && decodeURIComponent(partEnc); - if (part === "refresh") { + if (part === "reapply") { item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }; - return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" }); + return reply(200, { + 폴더: item.info.폴더, + 가져온날: "2026-09-30T02:00:00", + 원본판: "새판", + 덮임: ["면적"], + }); + } + if (part === "save-as" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const name = body.이름 ?? "복사"; + return reply(200, { + key: "SS900002", + 이름: name, + summary_item: { layer: "personal", owner: "1/3", key: "SS900002", name }, + }); } if (part === "calc") return reply(200, { @@ -122,17 +155,24 @@ const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner async function main() { const out = {}; - out.folders = [ - project.folderOf(row()), - project.folderOf(row({ layer: "company", owner: "3" })), - project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), - ]; + // 구조물 id → 복사본 폴더(서버 `folder_name`) · 관 지점 id(서버 `pipe@{round(측점, 3)}` 파이썬 float 글) + out.folders = ["st-1", "#3", "pipe@85.05"].map(project.structureFolder); + out.pipeIds = [85.05, 100, 258.12, 0.1234, 12.0004].map(project.pipeStructureId); const items = project.projectItems("P1"); out.listEmpty = await items.list(); - const info = await items.importItem(row({ layer: "personal", owner: "1/3" })); + const info = await items.importItem(row({ layer: "personal", owner: "1/3" }), "pipe@85.05"); out.info = info; out.importCall = state.calls.find((c) => c.method === "POST"); + out.sameItem = [ + project.sameItem(info, row({ layer: "personal", owner: "1/3" })), + project.sameItem(info, row({ layer: "personal", owner: "1/3", key: "SS000002" })), + project.sameItem(info, row()), + ]; + await items.importItem(row(), "").then( + () => (out.noStructure = "성공"), + (error) => (out.noStructure = error.message), + ); out.list = (await items.list()).map((x) => x.폴더); out.read = await items.read(info.폴더, "면적"); @@ -164,11 +204,25 @@ async function main() { ); state.readOnly = false; - const fresh = await items.refresh(info); - out.refresh = fresh; - out.refreshCall = state.calls - .filter((c) => /refresh$/.test(c.url)) + const again = await items.reapply(info); + out.reapply = again; + out.reapplyCall = state.calls + .filter((c) => /reapply$/.test(c.url)) .map((c) => `${c.method} ${c.url}`); + out.saveAs = await items.saveAs(info.폴더, ["sub5", "경로"], "내 항목"); + out.saveAsCall = state.calls.find((c) => /save-as$/.test(c.url)).body; + out.newItem = await items.newItem("pipe@85.05", ["개인", "폴더"], "새 항목", "m"); + out.newCall = state.calls.find((c) => /items\/new$/.test(c.url)).body; + state.noCompany = true; + await items.saveAs(info.폴더, [], "x").then( + () => (out.noCompanySave = "성공"), + (error) => (out.noCompanySave = error.message), + ); + await items.newItem("pipe@85.05", [], "x", "").then( + () => (out.noCompanyNew = "성공"), + (error) => (out.noCompanyNew = error.message), + ); + state.noCompany = false; out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 63956516..c261a3e4 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -36,26 +36,28 @@ def out(): return json.loads(proc.stdout) -def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): - rows = [ - {"layer": "system", "owner": None, "key": "SS000001"}, - {"layer": "company", "owner": "3", "key": "SS000001"}, - {"layer": "personal", "owner": "3/12", "key": "SS000002"}, +def test_구조물_id_는_복사본_폴더와_관_지점_id_로(out): + assert out["folders"] == ["st-1", "n3", "pipe@85.05"] # `#차례` = `n차례` (서버 folder_name) + # 서버 `pipe@{round(측점, 3)}` — 파이썬 float 글(정수도 `100.0`) + assert out["pipeIds"] == [ + f"pipe@{round(x, 3)}" for x in (85.05, 100.0, 258.12, 0.1234, 12.0004) ] - assert out["folders"] == [snapshot_folder_name(row) for row in rows] - assert out["info"]["폴더"] == snapshot_folder_name( - {"layer": "personal", "owner": "1/3", "key": "SS000001"} - ) -def test_가져오기는_POST_원본_참조_목록은_항목(out): +def test_가져오기는_구조물마다_POST_본문에_structure(out): assert out["listEmpty"] == [] assert out["importCall"] == { "method": "POST", "url": "/api/projects/P1/items", - "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, + "body": {"structure": "pipe@85.05", "layer": "personal", "owner": "1/3", "key": "SS000001"}, } - assert out["list"] == [out["info"]["폴더"]] + assert out["info"]["폴더"] == "pipe@85.05" # 폴더 = 구조물 id + assert out["list"] == ["pipe@85.05"] + assert out["noStructure"] == "미리 가져올 구조물(structure)이 필요합니다." # 서버 까닭 그대로 + + +def test_같은_구조물이_다른_항목으로_바뀌었는지_원본_참조로_가림(out): + assert out["sameItem"] == [True, False, False] def test_읽기는_내용만_돌려주고_없으면_null(out): @@ -76,14 +78,31 @@ def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로 assert out["readOnly"] == "프로젝트 편집 권한이 없음" -def test_새로_가져오기와_계산_한_줄(out): - assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/") - assert out["refreshCall"][0].endswith("/refresh") - assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판" +def test_재적용은_덮임을_돌려주고_계산_한_줄(out): + assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" + assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 + assert out["reapply"]["info"]["마스터바뀜"] is False assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 +def test_개인에_저장과_새_항목_본문_summary_item(out): + assert out["saveAsCall"] == {"to": "personal", "경로": ["sub5", "경로"], "이름": "내 항목"} + assert out["saveAs"]["summary_item"]["key"] == "SS900002" + assert out["newCall"] == { + "structure": "pipe@85.05", + "경로": ["개인", "폴더"], + "이름": "새 항목", + "단위": "m", + } + assert out["newItem"]["summary_item"]["name"] == "개인 폴더 새 항목" + + +def test_회사_없음_409_는_서버_까닭_그대로(out): + for key in ("noCompanySave", "noCompanyNew"): + assert out[key].startswith("회사부터 등록"), key + + def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out): assert out["inputsHits"] == 1 assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] @@ -164,3 +183,20 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_Inputs_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name + + +def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리(): + tree = (UI / "ui_template_tree.ts").read_text(encoding="utf-8") + assert "menu?: () => TreeMenuItem[]" in tree and "export function openTreeMenu(" in tree + assert '"contextmenu"' in tree + picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8") + # 새 항목 메뉴는 개인 그룹 · 폴더에만(마스터 · 회사는 메뉴 없음) · 프로젝트 안에서만 + assert 'folder.layer !== "personal" || !opts.projectId' in picker + assert "menu: menuOf(node)" in picker + + +def test_B05_구조물_폼이_구조물_id_를_모달에_넘김(): + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( + encoding="utf-8" + ) + assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts index be8ffa93..37f0c5db 100644 --- a/resources/tester/ui/item-picker-data.test.ts +++ b/resources/tester/ui/item-picker-data.test.ts @@ -10,6 +10,7 @@ import { isCurrent, itemName, pathIds, + pathOf, rowId, rowTip, shown, @@ -89,13 +90,8 @@ assert.equal( "옹벽 H=2m", ); const placeholderTree = buildTree([fresh], labels); -assert.equal(placeholderTree[2].children?.[0].label, "분류 없음"); // 종류 -assert.equal(placeholderTree[2].children?.[0].children?.[0].label, "분류 없음"); // 분류1 -assert.deepEqual(pathIds(fresh, labels), [ - "personal", - "personal|분류 없음", - "personal|분류 없음|분류 없음", -]); +assert.equal(placeholderTree[2].children?.[0].label, "n1"); // 경로가 비면 폴더 없이 그룹 바로 밑 잎 +assert.deepEqual(pathIds(fresh, labels), ["personal"]); // 툴팁 = 층 이름 + 키 assert.equal(rowTip(rows[7], labels), "개인 · SS000201"); @@ -123,14 +119,38 @@ assert.deepEqual( const cat = system.children?.[0].children?.[0]; assert.equal(cat?.label, "메쌓기"); assert.equal(cat?.count, 2); -const leaf = cat?.children?.[0]; +const size = cat?.children?.[0]; // 폴더 깊이 자유 — 경로 앞부분이 모두 폴더 +assert.equal(size?.label, "1.2m"); +assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] }); +assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로 +const leaf = size?.children?.[0]; assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유"); assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키 assert.equal(leaf?.count, null); assert.ok(leaf?.row); -// 분류가 빈 항목은 「분류 없음」 한 칸에 모임 -assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음"); +// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」) +assert.equal(tree[1].children?.[1].label, "옹벽"); +assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m"); +assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음 + +// 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌 +const deep = row("SS000400", "d1", "무시", ["무시", "", ""], "personal", { + 경로: ["가", "나", "다", "라", "마"], +}); +assert.deepEqual(pathOf(deep), ["가", "나", "다", "라", "마"]); +assert.equal(itemName(deep), "가 나 다 라 마"); +assert.deepEqual(pathIds(deep), [ + "personal", + "personal|가", + "personal|가|나", + "personal|가|나|다", + "personal|가|나|다|라", +]); +assert.deepEqual(pathOf({ key: "k", 열: "x", 경로: ["새 분류1", "옹벽"] }), ["옹벽"]); +const deepTree = buildTree([deep], labels)[2]; +assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라"); +assert.equal(deepTree.count, 1); // 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹) const onlySystem = buildTree( @@ -198,6 +218,7 @@ assert.deepEqual(pathIds(rows[0], labels), [ "system", "system|돌기슭막이", "system|돌기슭막이|메쌓기", + "system|돌기슭막이|메쌓기|1.2m", ]); // 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키 diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 4d8d73dd..44852a47 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -242,3 +242,30 @@ overflow: auto; } } + +/* 구조물 복사본 상태 줄 — 고침 · 마스터가 바뀜 · 원본 없음 표시 */ +.ui-item-picker__badge { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); +} + +.ui-item-picker__badge.is-warn { + border-color: var(--color-warning); + color: var(--color-text); + font-weight: 600; +} + +/* [적용] 뒤 구조물 [저장] 안내 한 줄 — 단추 왼쪽 */ +.ui-item-picker__saveline { + margin-right: auto; + align-self: center; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 글 묻는 작은 창(개인에 저장 · 새 항목) */ +.ui-modal.ui-item-ask { + width: min(28rem, 92vw); +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 5baec0eb..4062fbd8 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -3,8 +3,11 @@ * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). * - * const result = await openItemPicker({ current, canCopy }); + * const result = await openItemPicker({ current, canCopy, projectId, structureId }); * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" + * + * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. + * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -27,7 +30,9 @@ import { } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; -import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; +import { createTreeRow, renderTree, type TreeMenuItem, type TreeNode } from "./ui_template_tree"; +import { askFields } from "./ui_template_item_picker_ask"; +import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; @@ -40,6 +45,8 @@ export interface ItemPickerOptions { canCopy: boolean; /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ projectId?: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 복사본이 이 구조물 것 · 없으면(추가 전) 원본 보기만 */ + structureId?: string | null; } export type ItemPickerResult = @@ -88,6 +95,23 @@ async function copyToPersonal(row: PickerRow): Promise { return body?.key ?? null; } +const NEW_TEXT = { + menu: "새 항목", + title: "새 항목", + note: "{value} 안에 새 항목을 만듦 — 개인 층에도 저장되고 이 구조물에 지정됨", + name: "이름", + unit: "단위", + ok: "만들기", + cancel: "취소", + empty: "{label} 을(를) 넣을 것", + made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", + failed: "새 항목을 만들지 못함 — {value}", + noStructure: "구조물을 먼저 추가해야 새 항목을 이 구조물에 지정할 수 있음", + saveLine: "[적용] 뒤 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + const message = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -106,7 +130,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise openEdit(request), + // 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸) + onCreated: (created) => void load(created.key), }); const cols = el("div", { className: "ui-item-picker__cols", @@ -147,11 +174,25 @@ export function openItemPicker(opts: ItemPickerOptions): Promise TreeMenuItem[]) | undefined => { + const folder = node.folder; + if (!folder || folder.layer !== "personal" || !opts.projectId) return undefined; + return () => [ + { + label: NEW_TEXT.menu, + run: () => void createNew(folder.path), + disabledReason: opts.structureId ? undefined : NEW_TEXT.noStructure, + }, + ]; + }; + const toTree = (node: PickNode, searching: boolean): TreeNode => ({ id: node.id, label: node.label, count: node.count, tip: node.tip, + menu: menuOf(node), open: searching || opened.has(node.id), run: node.row ? () => choose(node.row as PickerRow) : undefined, children: node.children?.map((child) => toTree(child, searching)), @@ -234,9 +275,48 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if (!opts.projectId || !opts.structureId) return; + const where = [labels().layers.personal, ...path].join(" › "); + const got = await askFields({ + title: NEW_TEXT.title, + note: fill(NEW_TEXT.note, where), + fields: [ + { key: "name", label: NEW_TEXT.name, required: true }, + { key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소" }, + ], + okLabel: NEW_TEXT.ok, + cancelLabel: NEW_TEXT.cancel, + closeLabel: t("M02_Close"), + emptyText: NEW_TEXT.empty, + }); + if (!got) return; + try { + const made: CreatedItem = await projectItems(opts.projectId).newItem( + opts.structureId, + path, + got.name, + got.unit, + ); + showToast(NEW_TEXT.made, "success"); + finish({ action: "apply", item: made.summary_item as SummaryItem }); + } catch (error) { + showToast(fill(NEW_TEXT.failed, message(error)), "error"); // 회사 없음 409 = 「회사부터 등록」 그대로 + } + } + const actions = el("div", { className: "ui-item-picker__actions", - children: [copyButton, manageButton, clearButton, applyButton], + children: [ + ...(opts.projectId + ? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })] + : []), + copyButton, + manageButton, + clearButton, + applyButton, + ], }); /** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */ @@ -250,12 +330,13 @@ export function openItemPicker(opts: ItemPickerOptions): Promise | null> { + return new Promise((resolve) => { + let done = false; + const finish = (value: Record | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: opts.title, + closeLabel: opts.closeLabel, + dialogClass: "ui-item-ask", + backdropClass: "ui-item-ask__backdrop", + beforeClose: () => { + finish(null); + return true; + }, + mount: (body, close) => { + const inputs = opts.fields.map((field) => ({ + field, + input: createInputField({ + label: field.label, + value: field.value ?? "", + placeholder: field.placeholder, + }), + })); + const submit = (): void => { + let bad = false; + for (const { field, input } of inputs) { + const empty = field.required && input.input.value.trim() === ""; + input.setError(empty ? opts.emptyText.replace("{label}", field.label) : undefined); + bad ||= !!empty; + } + if (bad) return; + finish( + Object.fromEntries( + inputs.map(({ field, input }) => [field.key, input.input.value.trim()]), + ), + ); + close(); + }; + for (const { input } of inputs) + input.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") submit(); + }); + body.append( + ...(opts.note ? [el("p", { className: "ui-item-picker__where", text: opts.note })] : []), + ...inputs.map(({ input }) => input.root), + el("div", { + className: "ui-item-picker__actions", + children: [ + createButton({ + label: opts.cancelLabel, + variant: "ghost", + onClick: () => { + finish(null); + close(); + }, + }), + createButton({ label: opts.okLabel, variant: "filled", onClick: submit }), + ], + }), + ); + }, + }); + }); +} diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 7abeeda4..482a3b23 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,12 +1,14 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · - * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력). + * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * - * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). - * [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집). - * 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `ui_template_item_picker_edit.ts`. + * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, + * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 + * (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) · + * [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈 + * (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; @@ -19,20 +21,23 @@ import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_Master import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, itemName, layerOf, + pathOf, rowTip, - shown, type PickerLayer, type PickerRow, } from "./ui_template_item_picker_data"; import { - folderOf, loadInputs, PART_OF, projectItems, + sameItem, + structureFolder, + type CreatedItem, type ProjectItemInfo, type ProjectItems, } from "./ui_template_item_picker_project"; @@ -49,10 +54,14 @@ export interface EditRequest { export interface ItemDetailOptions { /** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */ projectId: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 아직 추가 전이면 null = 복사본 없음 */ + structureId: string | null; layerLabels: Record; hint: string; unit: string; onEdit: (request: EditRequest) => void; + /** [개인에 저장] 으로 개인 층에 새 항목이 생김 — 나무를 다시 받고 그 항목을 고름 */ + onCreated: (created: CreatedItem) => void; } export interface ItemDetail { @@ -60,24 +69,56 @@ export interface ItemDetail { show: (row: PickerRow | null) => void; /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ reload: () => void; + /** 이 구조물 복사본에 고침이 생김 — 목록을 다시 받지 않고 표시만 */ + markEdited: () => void; } const TEXT = { - importEdit: "프로젝트로 가져와 고침", - notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음", - imported: "프로젝트에 가져옴 {value}", - changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로", - refresh: "새로 가져오기", - refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?", - importFailed: "프로젝트로 가져오지 못함 — {value}", - refreshFailed: "새로 가져오지 못함 — {value}", + importEdit: "이 구조물로 가져와 고침", + noStructure: + "구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음", + notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음", + otherItem: "이 구조물의 복사본은 「{value}」 — [수정]하면 이 항목으로 바뀜", + otherEdited: + "이 구조물의 복사본 「{value}」 에서 고친 것이 있음 — 이 항목으로 바꾸면 그 고침은 _지난 폴더로 옮겨짐. 바꿀까요?", + imported: "이 구조물 복사본 · 가져옴 {value}", + edited: "고침 있음", + changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로", + gone: "원본이 없어짐 — 복사본은 그대로 씀", + reapply: "재적용", + reapplyTip: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임", + reapplyAsk: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 재료산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?", + reapplied: "재적용했음 — 덮인 것: {value}", + reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)", + reapplyFailed: "재적용하지 못함 — {value}", + saveAs: "개인에 저장", + saveAsTip: "이 구조물 복사본을 내 개인 층의 새 항목으로 저장 — 단가 · 넣은 값은 안 가져감", + saveAsTitle: "개인에 저장", + saveAsNote: "이 구조물 복사본을 개인 층 새 항목으로 저장함 — 경로는 폴더 이름을 「/」 로 이음", + pathLabel: "경로(폴더)", + nameLabel: "이름", + saved: "개인 층에 저장했음", + saveFailed: "개인에 저장하지 못함 — {value}", + ok: "확인", + cancel: "취소", + close: "닫기", + empty: "{label} 을(를) 넣을 것", + importFailed: "이 구조물로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", calcStuck: " · 멈춘 로직 {stuck}개", - loading: "읽는 중…", noCopy: "복사본에 없음", combo: "자재조합", price: "일위대가", + parts: { + 도면: "도면", + 면적: "면적산출", + 재료: "재료산출", + 자재조합: "자재조합", + 입력연결: "선택 입력 자리", + } as Record, }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -97,14 +138,19 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; + /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; + let other: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; + const folder = opts.structureId ? structureFolder(opts.structureId) : null; + /** 복사본을 고칠 수 있나 — 프로젝트 안 · 구조물이 있을 때 */ + const canCopy = !!items && !!folder; const small = (text: string): HTMLElement => el("p", { className: "ui-item-picker__where", text }); const paintHead = (row: PickerRow): void => { - const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)] + const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)] .filter(Boolean) .join(" › "); const pairs = folderLines(row).filter( @@ -126,7 +172,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); }; - /** 프로젝트 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ + /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ const makeSource = (row: PickerRow): ItemSource => info && opts.projectId ? { @@ -144,20 +190,28 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { read: (part, column) => readTemplate(layerOf(row), part, column, null), }; - const paintStatus = async (row: PickerRow, mine: number): Promise => { + const badge = (text: string, className = ""): HTMLElement => + el("span", { className: `ui-item-picker__badge ${className}`.trim(), text }); + + const paintStatus = (row: PickerRow, mine: number): void => { if (!items) return void (status.hidden = true); const nodes: (HTMLElement | string)[] = []; - if (!info) nodes.push(TEXT.notImported); + if (!folder) nodes.push(TEXT.noStructure); + else if (other) nodes.push(fill(TEXT.otherItem, other.이름 ?? other.원본.key)); + else if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); - if (info.마스터바뀜) { - const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); - button.addEventListener("click", () => void refresh(row)); - nodes.push( - el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }), - button, - ); - } + if (info.고침) nodes.push(badge(TEXT.edited)); + if (info.원본없음) nodes.push(badge(TEXT.gone, "is-warn")); + else if (info.마스터바뀜) nodes.push(badge(TEXT.changed, "is-warn")); + const reapply = createButton({ label: TEXT.reapply, variant: "ghost", size: "sm" }); + reapply.title = TEXT.reapplyTip; + reapply.disabled = !!info.원본없음; + reapply.addEventListener("click", () => void reapplyNow(row)); + const saveAs = createButton({ label: TEXT.saveAs, variant: "ghost", size: "sm" }); + saveAs.title = TEXT.saveAsTip; + saveAs.addEventListener("click", () => void saveAsNow(row)); + nodes.push(reapply, saveAs); } status.replaceChildren(...nodes); status.hidden = false; @@ -179,25 +233,66 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); } - async function refresh(row: PickerRow): Promise { - if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; + /** 목록은 모달에서 한 번만 받고 바뀐 줄만 갈아 끼움 */ + const upsert = (next: ProjectItemInfo): void => { + infos = [...(infos ?? []).filter((one) => one.폴더 !== next.폴더), next]; + }; + + async function reapplyNow(row: PickerRow): Promise { + if (!items || !info || !(await showConfirmDialog(TEXT.reapplyAsk, TEXT.reapply))) return; try { - const fresh = await items.refresh(info); - infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one)); + const got = await items.reapply(info); + upsert(got.info); + const names = got.covered.map((part) => TEXT.parts[part] ?? part); + showToast( + names.length ? fill(TEXT.reapplied, names.join(" · ")) : TEXT.reappliedNone, + "success", + ); show(row); } catch (error) { - showToast(fill(TEXT.refreshFailed, why(error)), "error"); + showToast(fill(TEXT.reapplyFailed, why(error)), "error"); } } - /** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */ + async function saveAsNow(row: PickerRow): Promise { + if (!items || !info) return; + const got = await askFields({ + title: TEXT.saveAsTitle, + note: TEXT.saveAsNote, + fields: [ + { key: "path", label: TEXT.pathLabel, value: pathOf(row).slice(0, -1).join("/") }, + { key: "name", label: TEXT.nameLabel, value: itemName(row), required: true }, + ], + okLabel: TEXT.ok, + cancelLabel: TEXT.cancel, + closeLabel: TEXT.close, + emptyText: TEXT.empty, + }); + if (!got) return; + try { + const path = got.path + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const created = await items.saveAs(info.폴더, path, got.name); + showToast(TEXT.saved, "success"); + opts.onCreated(created); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); // 회사 없음 409 등 서버 까닭 그대로 + } + } + + /** [수정] — 이 구조물에 아직 없으면(또는 다른 항목이면) 먼저 이 구조물로 가져옴 */ async function edit(kind: EditKind | "inputs"): Promise { const row = current; - if (!row || !items || !opts.projectId) return; + if (!row || !items || !opts.projectId || !opts.structureId) return; if (!info) { + const ask = other?.고침 ? fill(TEXT.otherEdited, other.이름 ?? other.원본.key) : null; + if (ask && !(await showConfirmDialog(ask))) return; try { - info = await items.importItem(row); - infos = [...(infos ?? []), info]; + info = await items.importItem(row, opts.structureId); + other = null; + upsert(info); } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; @@ -234,7 +329,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, - editable: () => !!items, + editable: () => canCopy, onEdit: (kind) => void edit(kind), warnOnFail: () => false, extraCards: [inputs.root], @@ -256,25 +351,34 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { paintHead(row); void (async () => { try { - if (items) infos ??= await items.list(); + if (items && folder) infos ??= await items.list(); } catch (error) { if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); infos = []; } if (mine !== seq) return; - info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null; + const own = infos?.find((one) => one.폴더 === folder) ?? null; + info = own && sameItem(own, row) ? own : null; + other = own && !info ? own : null; source = makeSource(row); await ensureCards(); if (mine !== seq || !cards || !inputs) return; - const label = info ? null : TEXT.importEdit; + const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label, material: label }); inputs.setEditLabel(label ?? "선택 입력 수정"); cards.root.hidden = false; - void paintStatus(row, mine); + paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); void paintInputs(row, mine); })(); } - return { root, show, reload: () => show(current) }; + return { + root, + show, + reload: () => show(current), + markEdited: () => { + if (info) upsert({ ...info, 고침: true }); + }, + }; } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index abe7996c..9aa0b4b6 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -48,10 +48,11 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -export function createEditView(req: EditRequest, onBack: () => void): EditView { +export function createEditView(req: EditRequest, onBack: (saved: boolean) => void): EditView { const { kind, row, info, items } = req; let dirty = false; let saving = false; + let saved = false; // 한 번이라도 저장했나 — 돌아갈 때 「고침 있음」 표시 let doc: unknown = null; let drawing: DrawingTemplateHandle | null = null; let sheet: SpreadsheetHandle | null = null; @@ -191,6 +192,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { doc = merged; } setDirty(false); + saved = true; showToast(TEXT.saved, "success"); } catch (error) { showToast(fill(TEXT.saveFailed, why(error)), "error"); @@ -211,7 +213,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { save.addEventListener("click", () => void doSave()); back.addEventListener("click", async () => { - if (await leave()) onBack(); + if (await leave()) onBack(saved); }); return { root, start: () => void start(), isDirty: () => dirty, leave, destroy }; diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 7275b48a..27366224 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -2,11 +2,13 @@ * ui_template_item_picker_project.ts * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * - * 프로젝트 복사본(sub6 · 25-2b) - * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]} - * POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로) + * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} + * POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item} * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) - * POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림) + * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} + * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` * ========================================================================== */ @@ -42,9 +44,27 @@ export interface ProjectItemInfo { 고침?: boolean; } -/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ -export const folderOf = (row: PickerRow): string => - `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; +/** 구조물 id → 복사본 폴더 이름 — 서버 `folder_name` 과 같은 글(id 없는 옛 구조물 `#차례` = `n차례`) */ +export const structureFolder = (structureId: string): string => structureId.replace("#", "n"); + +/** 관 지점의 구조물 id — 서버 `pipe@{round(측점, 3)}`(파이썬 float 글: 정수도 `100.0`) */ +export function pipeStructureId(chainageM: number): string { + const rounded = Math.round(chainageM * 1000) / 1000; + return `pipe@${Number.isInteger(rounded) ? rounded.toFixed(1) : String(rounded)}`; +} + +/** 이 복사본이 그 항목의 것인가 — 같은 구조물이 다른 항목으로 바뀌면 원본 참조가 다름 */ +export const sameItem = (info: ProjectItemInfo, row: PickerRow): boolean => + info.원본.layer === layerOf(row) && + (info.원본.owner ?? null) === ownerOf(row) && + info.원본.key === row.key; + +/** 개인 층에 새로 생긴 항목 — `summary_item` 을 폼에 지정하면 됨 */ +export interface CreatedItem { + key: string; + 이름?: string; + summary_item: { layer: string; owner: string | null; key: string; name: string }; +} /** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ export interface ProjectCalc { @@ -94,9 +114,18 @@ export interface ProjectItems { /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ - importItem: (row: PickerRow) => Promise; - /** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */ - refresh: (info: ProjectItemInfo) => Promise; + importItem: (row: PickerRow, structureId: string) => Promise; + /** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */ + reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>; + /** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */ + saveAs: (folder: string, path: string[], name: string) => Promise; + /** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */ + newItem: ( + structureId: string, + path: string[], + name: string, + unit: string, + ) => Promise; /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ calc: (folder: string) => Promise; } @@ -109,6 +138,10 @@ export function projectItems(projectId: string): ProjectItems { if (kept) return kept; const base = `/projects/${enc(projectId)}/items`; const versions = new Map(); + /** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */ + const forget = (folder: string): void => { + for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key); + }; const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; const client: ProjectItems = { @@ -136,27 +169,50 @@ export function projectItems(projectId: string): ProjectItems { if (reply.status >= 400) throw new Error(reasonOf(reply)); versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, - importItem: async (row) => { + importItem: async (row, structureId) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; - const reply = await call(base, post(ref)); + const reply = await call(base, post({ structure: structureId, ...ref })); if (reply.status >= 300) throw new Error(reasonOf(reply)); const got = reply.body as { 폴더: string; 가져온날?: string | null }; + forget(got.폴더); return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; }, - refresh: async (info) => { - const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({})); + reapply: async (info) => { + const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({})); if (reply.status >= 300) throw new Error(reasonOf(reply)); - for (const key of [...versions.keys()]) - if (key.startsWith(`${info.폴더}/`)) versions.delete(key); + forget(info.폴더); inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음 - const got = reply.body as { 가져온날?: string | null; 원본판?: string | null }; - return { - ...info, - 가져온날: got.가져온날, - 원본판: got.원본판, - 마스터바뀜: false, - 고침: false, + const got = reply.body as { + 가져온날?: string | null; + 원본판?: string | null; + 덮임?: string[]; }; + return { + info: { + ...info, + 가져온날: got.가져온날, + 원본판: got.원본판, + 마스터바뀜: false, + 고침: false, + }, + covered: got.덮임 ?? [], + }; + }, + saveAs: async (folder, path, name) => { + const reply = await call( + `${base}/${enc(folder)}/save-as`, + post({ to: "personal", 경로: path, 이름: name }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; + }, + newItem: async (structureId, path, name, unit) => { + const reply = await call( + `${base}/new`, + post({ structure: structureId, 경로: path, 이름: name, 단위: unit }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; }, calc: async (folder) => { const reply = await call(`${base}/${enc(folder)}/calc`, post({}));