Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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("없는폴더");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 · 없으면 키만(작은 글) · 둘 다 없으면 빈 키
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<ItemPickerResul
|
||||
layerLabels: labels().layers,
|
||||
hint: t("M02_PickerHint"),
|
||||
unit: t("M02_PickerUnit"),
|
||||
structureId: opts.structureId ?? null,
|
||||
onEdit: (request) => 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<ItemPickerResul
|
||||
paintDetail();
|
||||
};
|
||||
|
||||
/** 오른쪽 클릭 — 개인 그룹 · 폴더만 「새 항목」(경로 = 누른 폴더) */
|
||||
const menuOf = (node: PickNode): (() => 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<ItemPickerResul
|
||||
}
|
||||
});
|
||||
|
||||
/** 프로젝트에서 새 항목 — 개인 층에도 만들고 돌려받은 `summary_item` 을 폼에 지정 */
|
||||
async function createNew(path: string[]): Promise<void> {
|
||||
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<ItemPickerResul
|
||||
view.start();
|
||||
}
|
||||
/** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */
|
||||
function closeEdit(): void {
|
||||
function closeEdit(saved: boolean): void {
|
||||
editView?.destroy();
|
||||
editView = null;
|
||||
editHost.hidden = true;
|
||||
cols.hidden = false;
|
||||
actions.hidden = false;
|
||||
if (saved) detailView.markEdited();
|
||||
detailView.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_ask.ts
|
||||
* 고르기 모달에서 글을 물을 때 쓰는 작은 입력 창(계획서 26-3b) — [개인에 저장](경로 · 이름) ·
|
||||
* 나무 오른쪽 클릭 「새 항목」(이름 · 단위). 확인 = 넣은 글들 · 취소 · 닫기 = null.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "./ui_template_elements";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
|
||||
export interface AskField {
|
||||
key: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
/** 비우면 확인 안 됨 */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface AskOptions {
|
||||
title: string;
|
||||
/** 창 위 한 줄 설명 */
|
||||
note?: string;
|
||||
fields: AskField[];
|
||||
okLabel: string;
|
||||
cancelLabel: string;
|
||||
closeLabel: string;
|
||||
/** 빈 값 알림 — `{label}` 자리에 칸 이름 */
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
export function askFields(opts: AskOptions): Promise<Record<string, string> | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value: Record<string, string> | 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 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<PickerLayer, string>;
|
||||
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<string, string>,
|
||||
};
|
||||
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<void>;
|
||||
/** 프로젝트로 미리 가져옴 */
|
||||
importItem: (row: PickerRow) => Promise<ProjectItemInfo>;
|
||||
/** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */
|
||||
refresh: (info: ProjectItemInfo) => Promise<ProjectItemInfo>;
|
||||
importItem: (row: PickerRow, structureId: string) => Promise<ProjectItemInfo>;
|
||||
/** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */
|
||||
reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>;
|
||||
/** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */
|
||||
saveAs: (folder: string, path: string[], name: string) => Promise<CreatedItem>;
|
||||
/** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */
|
||||
newItem: (
|
||||
structureId: string,
|
||||
path: string[],
|
||||
name: string,
|
||||
unit: string,
|
||||
) => Promise<CreatedItem>;
|
||||
/** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */
|
||||
calc: (folder: string) => Promise<ProjectCalc | null>;
|
||||
}
|
||||
@@ -109,6 +138,10 @@ export function projectItems(projectId: string): ProjectItems {
|
||||
if (kept) return kept;
|
||||
const base = `/projects/${enc(projectId)}/items`;
|
||||
const versions = new Map<string, string | null>();
|
||||
/** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */
|
||||
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({}));
|
||||
|
||||
Reference in New Issue
Block a user