- 카드 · 편집이 그 구조물의 복사본(폴더 = 구조물 id · 관 = pipe@측점)을 읽고 고침 · 같은 항목을 고른 구조물끼리 값이 따로 - [재적용](덮이는 것을 확인 창으로 알리고 덮임 부분을 알림) · [개인에 저장](경로 · 이름 물음) · 상태 줄에 고침 · 마스터가 바뀜 · 원본 없음 표시 - 나무 개인 그룹 · 폴더 오른쪽 클릭 「새 항목」 → 돌려받은 summary_item 을 폼에 지정 · [적용] 뒤 [저장] 안내 한 줄 - B05 구조물 폼이 구조물 id 를 모달에 넘김 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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 });
|
||
},
|
||
};
|
||
}
|