Files
Aislo/ui_template/ui_template_item_picker_detail.ts
T
eomsangdonandClaude Opus 5.5 bcb1c725c4 refactor(M02): 항목 카드 넷 공용 틀 CardShell (PLAN 31-7)
- 공용 틀 — 제목 줄(제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 말줄임) · 상태(불러오는 중 · 실패 + 다시 불러오기 · 비었음) · [저장](늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 알림 글 한 벌) · ?(단축키 상자)
- 도면 정보 · 면적산출 · 재료산출(ItemCards) · 선택 입력(관리 화면 · 고르기 모달 값 카드)이 틀에 내용만 넣음
- 「〇〇 수정」 빈 단추 한 모양 · 엑셀 카드에도 ? · 불러오는 중 네 카드 · 실패는 카드 안 글(토스트 안 씀) · 물음 한 번(어느 카드든)
- 저장 길(saveTemplate · 도면 write · sheetEdit.save · 위 [저장] saveInputs) · 단축키는 그대로 · 고르기 모달은 보기만
- 시험 test_m02_card_shell 새로 · 옛 모양 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:24:49 +09:00

388 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;
const stop = inputs.loading(); // 카드 안 「불러오는 중」(M02 31-7)
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);
}
stop();
if (mine === seq) inputs.set({ list, link, values });
} catch (error) {
stop();
if (mine === seq) inputs.set(null, why(error), () => void paintInputs(row, seq));
}
}
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 });
},
};
}