Files
Aislo/ui_template/ui_template_item_picker_detail.ts
T
eomsangdonandClaude Opus 5.5 c704f18982 refactor(M02): 선택 입력 지움(39-7b G3) — 카드 · 자리 고르기 · inputs/ 연결 · 입력 목록 · 연결 · 알약 길 · 프로젝트 복사본 「입력연결」 · 시스템 층 inputs · 시험
- 넣을 값 카드(M02_Labor_Values)는 담은 로직 입력 전부를 보임(자리 「선택」 감싸기 없앰) · 이름 「넣을 값」
- 고르기 모달 면적산출 알약 = 복사본 일위대가 알약(가져온 때 그대로)
- 입력값.json · 고른자재.json · /calc 는 그대로 · storage 층 inputs/ 파일은 안 건드림(읽기만 끊음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-04 19:36:46 +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 · 39-1c) — 카드 = 도면 · 일위대가 · 면적산출 ·
* 넣을 값. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `M02_Labor_Values`) + 일위대가 카드
* (`ui_template_item_picker_labor`)를 늦게 받아 씀(B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
* 넣을 값 = 일위대가 담은 로직 입력 전부(복사본이면 복사본 입력값).
*
* 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고,
* 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」
* (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) ·
* [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈
* (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`.
* ========================================================================== */
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
import { readTemplate } 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_Labor_Values";
import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model";
import type { LaborCardHandle } from "./ui_template_item_picker_labor";
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 {
laborOf,
laborValueData,
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: "복사본에 없음",
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 labor: LaborCardHandle | 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 === "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(`${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 });
}
/** 일위대가 카드 · 넣을 값 카드 — 복사본이 있으면 복사본(값도) · 아니면 원본 층(39-1c) */
async function paintLabor(row: PickerRow, mine: number): Promise<void> {
if (!inputs || !labor) return;
const stops = [labor.loading(), inputs.loading()]; // 카드 안 「불러오는 중」(M02 31-7)
try {
const copy = info && items ? info.폴더 : null;
const summary = await laborOf(row, copy ? items : null, copy);
const values = copy ? (((await items?.read(copy, "입력값")) as ValueDoc | null) ?? {}) : {};
const data = summary ? await laborValueData(summary.로직, values) : null;
stops.forEach((stop) => stop());
if (mine !== seq) return;
labor.set(summary);
inputs.set(data);
} catch (error) {
stops.forEach((stop) => stop());
if (mine !== seq) return;
const retry = (): void => void paintLabor(row, seq);
labor.set(null, why(error), retry);
inputs.set(null, why(error), retry);
}
}
async function ensureCards(): Promise<void> {
if (cards && inputs && labor) return;
const [cardModule, inputModule, laborModule] = await Promise.all([
import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"),
import("../M02_MasterTemplete/M02_Labor_Values"),
import("./ui_template_item_picker_labor"),
]);
labor = laborModule.createLaborCard();
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],
});
// 차례 = 도면 · 일위대가 · 면적산출 — 일위대가 카드를 도면 카드 바로 뒤에
cards.root.insertBefore(labor.root, cards.root.children[1] ?? null);
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;
labor = 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 || !labor) return;
const label = !info && canCopy ? TEXT.importEdit : null;
cards.relabel({ structure: label, area: label });
inputs.setEditLabel(label ?? "넣을 값 수정");
cards.root.hidden = false;
paintStatus(row, mine);
void cards.load({ id: row.열, label: itemName(row) });
void paintLabor(row, mine);
})();
}
return {
root,
show,
reload: () => show(current),
markEdited: () => {
if (info) upsert({ ...info, 고침: true });
},
};
}