Files
Aislo/ui_template/ui_template_item_picker_detail.ts
T
eomsangdonandClaude Sonnet 5.5 26eac086a8 feat(ui_template): 고르기 모달 — 구조물 복사본 기준 · [재적용] · [개인에 저장] · 나무 오른쪽 클릭 새 항목 · 경로 이름 (PLAN 26-3b)
- 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로
- [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시
- 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄
- B05 구조물 폼이 구조물 id 를 모달에 넘김

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:15:55 +09:00

385 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_item_picker_detail.ts
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력).
* 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
*
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
* (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) ·
* [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈
* (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import {
readInputLinks,
readTemplate,
type InputLinks,
} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards";
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,
type PickerLayer,
type PickerRow,
} from "./ui_template_item_picker_data";
import {
loadInputs,
PART_OF,
projectItems,
sameItem,
structureFolder,
type CreatedItem,
type ProjectItemInfo,
type ProjectItems,
} from "./ui_template_item_picker_project";
/** 편집 화면을 여는 데 필요한 것 — 「선택 입력」 도 한 종류 */
export interface EditRequest {
kind: EditKind | "inputs";
row: PickerRow;
info: ProjectItemInfo;
items: ProjectItems;
projectId: string;
}
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 {
root: HTMLElement;
show: (row: PickerRow | null) => void;
/** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */
reload: () => void;
/** 이 구조물 복사본에 고침이 생김 — 목록을 다시 받지 않고 표시만 */
markEdited: () => void;
}
const TEXT = {
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}개",
noCopy: "복사본에 없음",
combo: "자재조합",
price: "일위대가",
parts: {
도면: "도면",
면적: "면적산출",
재료: "재료산출",
자재조합: "자재조합",
입력연결: "선택 입력 자리",
} as Record<string, string>,
};
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const fill = (text: string, value: string): string => text.replace("{value}", value);
const dateOf = (iso?: string | null): string => (iso ? iso.slice(0, 10) : "");
export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
const root = el("div", { className: "ui-item-picker__detail" });
const head = el("div", { className: "ui-item-picker__head" });
const status = el("div", { className: "ui-item-picker__status", attrs: { hidden: "" } });
const cardsHost = el("div", { className: "ui-item-picker__cards" });
root.append(head, status, cardsHost);
let current: PickerRow | null = null;
let seq = 0;
let infos: ProjectItemInfo[] | null = null;
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)], ...pathOf(row).slice(0, -1)]
.filter(Boolean)
.join(" › ");
const pairs = folderLines(row).filter(
(line) => line.kind === "combo" || line.kind === "unitPrice",
);
head.replaceChildren(
el("h4", {
className: "ui-item-picker__name",
text: itemName(row),
attrs: { title: rowTip(row, { layers: opts.layerLabels, unclassified: "" }) },
}),
small(where),
...(row.단위 ? [small(`${opts.unit} ${row.단위}`)] : []),
...pairs
.filter((line) => line.name || line.key)
.map((line) =>
small(`${line.kind === "combo" ? TEXT.combo : TEXT.price} ${line.name || line.key}`),
),
);
};
/** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */
const makeSource = (row: PickerRow): ItemSource =>
info && opts.projectId
? {
layer: "project",
projectId: opts.projectId,
read: async (part) => {
const doc = await items?.read(info!.폴더, PART_OF[part]);
if (doc == null) throw new Error(TEXT.noCopy);
return { 문서: doc };
},
}
: {
layer: layerOf(row),
projectId: null,
read: (part, column) => readTemplate(layerOf(row), part, column, null),
};
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 (!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.고침) 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;
if (info) void paintCalc(info, mine);
};
/** 복사본만으로 계산한 계 · 빠진 입력을 한 줄로(값을 넣고 돌아오면 달라짐) */
async function paintCalc(from: ProjectItemInfo, mine: number): Promise<void> {
const got = await items?.calc(from.폴더).catch(() => null);
if (!got || mine !== seq) return;
const total = Math.round(got.total).toLocaleString();
status.append(
el("span", {
className: "ui-item-picker__calc",
text:
TEXT.calc.replace("{total}", total).replace("{gaps}", String(got.gaps)) +
(got.stuck ? TEXT.calcStuck.replace("{stuck}", String(got.stuck)) : ""),
}),
);
}
/** 목록은 모달에서 한 번만 받고 바뀐 줄만 갈아 끼움 */
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 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.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 || !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, opts.structureId);
other = null;
upsert(info);
} catch (error) {
showToast(fill(TEXT.importFailed, why(error)), "error");
return;
}
}
opts.onEdit({ kind, row, info, items, projectId: opts.projectId });
}
async function paintInputs(row: PickerRow, mine: number): Promise<void> {
if (!inputs) return;
try {
const list = await loadInputs(row);
let link: InputLinks | null;
let values: ValueDoc = {};
if (info && items) {
// 복사본 것(가져온 때 그대로) — 값도 복사본에서
link = (await items.read(info.폴더, "입력연결")) as InputLinks | null;
values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
} else {
link = await readInputLinks(layerOf(row), row.key).catch(() => null);
}
if (mine === seq) inputs.set({ list, link, values });
} catch (error) {
if (mine === seq) inputs.set(null, why(error));
}
}
async function ensureCards(): Promise<void> {
if (cards && inputs) return;
const [cardModule, inputModule] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
import("../M02_MasterTemplete/M02_MasterTemplete_Inputs"),
]);
inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") });
cards = cardModule.createItemCards({
source: () => source,
editable: () => canCopy,
onEdit: (kind) => void edit(kind),
warnOnFail: () => false,
extraCards: [inputs.root],
});
cardsHost.replaceChildren(cards.root);
}
function show(row: PickerRow | null): void {
const mine = ++seq;
current = row;
if (!row) {
head.replaceChildren(el("p", { className: "ui-item-picker__hint", text: opts.hint }));
status.hidden = true;
cardsHost.replaceChildren();
cards = null;
inputs = null;
return;
}
paintHead(row);
void (async () => {
try {
if (items && folder) infos ??= await items.list();
} catch (error) {
if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error");
infos = [];
}
if (mine !== seq) return;
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 && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label, material: label });
inputs.setEditLabel(label ?? "선택 입력 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
void paintInputs(row, mine);
})();
}
return {
root,
show,
reload: () => show(current),
markEdited: () => {
if (info) upsert({ ...info, 고침: true });
},
};
}