feat(ui_template): 항목 고르기 모달 상세 — 카드 넷 · 프로젝트 복사본 편집 · 뒤로 (PLAN 25-2d)
- 오른쪽 상세를 카드 넷(도면 · 면적산출 · 재료산출 · 선택 입력)으로 — M02 와 같은 카드 한 벌 - 카드 [수정] = 모달 안 편집 화면(CAD · 스프레드시트 · 선택 입력 값 넣기) · [뒤로] = 선택지 - 고침은 프로젝트 복사본만 · 안 가져온 항목은 「프로젝트로 가져와 고침」 · 마스터가 바뀜 표시 + [새로 가져오기] - 서버 길(sub6 · sub4)이 없으면 약속 모양의 가짜 응답(이음 자리 FAKE) - B05 구조물 폼이 projectId 를 모달에 넘김 · 모달 넓힘 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
@@ -186,7 +186,7 @@ export function sharedStructures(shared: DesignPageShared): StructuresSection {
|
||||
onPipeSelect: (chainageM) => on().onPipeSelect?.(chainageM),
|
||||
onReveal: () => shared.revealLeft(),
|
||||
},
|
||||
{ includeDetail: () => shared.view() === "cross" },
|
||||
{ includeDetail: () => shared.view() === "cross", projectId: shared.projectId },
|
||||
);
|
||||
shared.parts.structures = shared.structures;
|
||||
return shared.structures;
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface StructureOptionField {
|
||||
export interface StructuresSectionOptions {
|
||||
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
|
||||
includeDetail?: boolean | (() => boolean);
|
||||
/** 집계 항목 고르기 모달이 프로젝트 복사본을 고치려면 필요 — 없으면 원본 보기만(25-2d) */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
|
||||
|
||||
@@ -497,7 +497,11 @@ export function createStructuresSection(
|
||||
summary.bind({
|
||||
pick: () => {
|
||||
// canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임
|
||||
void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => {
|
||||
void openItemPicker({
|
||||
current: summary.get(),
|
||||
canCopy: true,
|
||||
projectId: sectionOptions.projectId ?? null,
|
||||
}).then((result) => {
|
||||
if (result.action === "cancel") return;
|
||||
summary.set(result.action === "apply" ? result.item : null);
|
||||
commitSummary();
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface ItemCardsHandle {
|
||||
load: (col: { id: string; label: string } | null) => Promise<void>;
|
||||
/** 이름만 새로(CAD 를 다시 안 띄움) */
|
||||
setLabel: (label: string) => void;
|
||||
/** [수정] 단추 글 — 종류마다 · `null` 은 기본 글 */
|
||||
relabel: (labels: Partial<Record<EditKind, string | null>>) => void;
|
||||
/** 지금 보이는 열 id */
|
||||
column: () => string | null;
|
||||
destroy: () => void;
|
||||
@@ -142,6 +144,16 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
};
|
||||
const sheets = [sheetCard("area"), sheetCard("material")];
|
||||
edit.addEventListener("click", () => opts.onEdit("structure"));
|
||||
const buttons: Record<EditKind, HTMLButtonElement> = {
|
||||
structure: edit,
|
||||
area: sheets[0].edit,
|
||||
material: sheets[1].edit,
|
||||
};
|
||||
const defaults: Record<EditKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
area: fill(TEXT.editSheet, PART_WORD.area),
|
||||
material: fill(TEXT.editSheet, PART_WORD.material),
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
className: "m02-struct ui-sidebar-panel",
|
||||
@@ -267,6 +279,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
setLabel: (label) => {
|
||||
columnName.textContent = label;
|
||||
},
|
||||
relabel: (labels) => {
|
||||
for (const kind of Object.keys(buttons) as EditKind[]) {
|
||||
const button = buttons[kind];
|
||||
(button.querySelector(".ui-btn__label") ?? button).textContent =
|
||||
labels[kind] ?? defaults[kind];
|
||||
}
|
||||
},
|
||||
column: () => column,
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/* 항목 고르기 모달 — 왼쪽 나무 · 오른쪽 폴더 모양 · 아래 단추 */
|
||||
.ui-item-picker {
|
||||
width: min(900px, 94vw);
|
||||
width: min(1320px, 96vw);
|
||||
max-height: 94vh;
|
||||
}
|
||||
|
||||
.ui-item-picker .ui-modal__body {
|
||||
height: min(60vh, 520px);
|
||||
height: min(80vh, 800px);
|
||||
}
|
||||
|
||||
.ui-item-picker__cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 5fr) minmax(260px, 6fr);
|
||||
grid-template-columns: minmax(220px, 3fr) minmax(320px, 9fr);
|
||||
gap: var(--spacing-12);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -47,6 +48,115 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__cols[hidden],
|
||||
.ui-item-picker__actions[hidden],
|
||||
.ui-item-picker__edit-host[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-item-picker__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 프로젝트 복사본 상태 줄 — 가져온 날 · 마스터가 바뀜 · [새로 가져오기] */
|
||||
.ui-item-picker__status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__changed {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-item-picker__fake {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* 카드 넷 — M02 와 같은 카드 · 칸이 좁으면 한 줄씩(카드 하나가 300px 안 되면) */
|
||||
.ui-item-picker__cards {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__cards .m02-struct {
|
||||
flex: 1 1 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 편집 화면 — 뒤로 · 제목 · 저장 한 줄 + 편집 부품이 남는 높이를 채움 */
|
||||
.ui-item-picker__edit-host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-title {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-heading);
|
||||
font-size: var(--text-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-note {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-sheet,
|
||||
.ui-item-picker__edit-cad {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-inputs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-8);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ui-item-picker__edit-inputs .m02-struct__card {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ui-item-picker__name {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
@@ -104,3 +214,12 @@
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 나무는 위(낮게) · 상세는 아래로 쌓고 카드는 한 줄씩 */
|
||||
@media (max-width: 760px) {
|
||||
.ui-item-picker__cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 2fr) minmax(0, 3fr);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,12 @@ import { createButton, createInputField, el, showToast } from "./ui_template_ele
|
||||
import {
|
||||
buildTree,
|
||||
filterRows,
|
||||
folderLines,
|
||||
isCurrent,
|
||||
itemName,
|
||||
layerOf,
|
||||
ownerOf,
|
||||
pathIds,
|
||||
rowId,
|
||||
rowTip,
|
||||
shown,
|
||||
toSummary,
|
||||
type FolderKind,
|
||||
type PickNode,
|
||||
type PickerLayer,
|
||||
type PickerRow,
|
||||
@@ -33,6 +28,8 @@ import {
|
||||
import { t } from "./ui_template_locale";
|
||||
import { openModal } from "./ui_template_modal";
|
||||
import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree";
|
||||
import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { createEditView, type EditView } from "./ui_template_item_picker_edit";
|
||||
|
||||
export type { SummaryItem } from "./ui_template_item_picker_data";
|
||||
|
||||
@@ -41,6 +38,8 @@ export interface ItemPickerOptions {
|
||||
current: SummaryItem | null;
|
||||
/** [개인용으로 복사]를 쓸 수 있나 — 개인 층에 쓸 수 있을 때(회사 등록 뒤) */
|
||||
canCopy: boolean;
|
||||
/** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export type ItemPickerResult =
|
||||
@@ -48,14 +47,6 @@ export type ItemPickerResult =
|
||||
|
||||
const MANAGE_HASH = "#/m02-master-template?layer=personal";
|
||||
|
||||
const FOLDER_LABEL: Record<FolderKind, () => string> = {
|
||||
drawing: () => t("M02_PickerDrawing"),
|
||||
area: () => t("M02_PickerArea"),
|
||||
material: () => t("M02_PickerMaterial"),
|
||||
combo: () => t("M02_PickerCombo"),
|
||||
unitPrice: () => t("M02_PickerUnitPrice"),
|
||||
};
|
||||
|
||||
const labels = (): TreeLabels => ({
|
||||
layers: {
|
||||
system: t("M02_PickerSystem"),
|
||||
@@ -110,7 +101,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
|
||||
const search = createInputField({ placeholder: t("M02_PickerSearch"), compact: true });
|
||||
const treeHost = el("div", { className: "ui-item-picker__tree" });
|
||||
const detail = el("div", { className: "ui-item-picker__detail" });
|
||||
const detailView = createItemDetail({
|
||||
projectId: opts.projectId ?? null,
|
||||
layerLabels: labels().layers,
|
||||
hint: t("M02_PickerHint"),
|
||||
unit: t("M02_PickerUnit"),
|
||||
onEdit: (request) => openEdit(request),
|
||||
});
|
||||
const cols = el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detailView.root,
|
||||
],
|
||||
});
|
||||
const editHost = el("div", { className: "ui-item-picker__edit-host", attrs: { hidden: "" } });
|
||||
let editView: EditView | null = null;
|
||||
const applyButton = createButton({ label: t("M02_PickerApply"), variant: "filled" });
|
||||
const clearButton = createButton({ label: t("M02_PickerClear"), variant: "ghost" });
|
||||
const copyButton = createButton({ label: t("M02_PickerCopy"), variant: "ghost" });
|
||||
@@ -126,47 +132,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
el("p", { className: "ui-item-picker__hint", text });
|
||||
|
||||
const paintDetail = (): void => {
|
||||
if (!chosen) {
|
||||
detail.replaceChildren(hint(t("M02_PickerHint")));
|
||||
} else {
|
||||
const where = [labels().layers[layerOf(chosen)], shown(chosen.종류), shown(chosen.분류1)]
|
||||
.filter(Boolean)
|
||||
.join(" › ");
|
||||
const small = (text: string): HTMLElement =>
|
||||
el("p", { className: "ui-item-picker__where", text });
|
||||
detail.replaceChildren(
|
||||
el("h4", {
|
||||
className: "ui-item-picker__name",
|
||||
text: itemName(chosen),
|
||||
attrs: { title: rowTip(chosen, labels()) },
|
||||
}),
|
||||
small(where),
|
||||
...(chosen.단위 ? [small(`${t("M02_PickerUnit")} ${chosen.단위}`)] : []),
|
||||
el("ul", {
|
||||
className: "ui-item-picker__folder",
|
||||
children: folderLines(chosen).map((line) =>
|
||||
el("li", {
|
||||
attrs: line.name && line.key ? { title: line.key } : {},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-item-picker__folder-label",
|
||||
text: FOLDER_LABEL[line.kind](),
|
||||
}),
|
||||
// 이름이 있으면 이름(키는 툴팁) · 없으면 키를 작은 글로 · 둘 다 없으면 「없음」
|
||||
el("span", {
|
||||
className: line.name
|
||||
? ""
|
||||
: line.key
|
||||
? "ui-item-picker__key"
|
||||
: "ui-item-picker__none",
|
||||
text: line.name || line.key || t("M02_PickerNone"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
detailView.show(chosen);
|
||||
const layer = chosen ? layerOf(chosen) : null;
|
||||
applyButton.disabled = !chosen;
|
||||
clearButton.disabled = !opts.current;
|
||||
@@ -268,29 +234,44 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
}
|
||||
});
|
||||
|
||||
const actions = el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
});
|
||||
|
||||
/** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */
|
||||
function openEdit(request: EditRequest): void {
|
||||
const view = createEditView(request, closeEdit);
|
||||
editView = view;
|
||||
editHost.replaceChildren(view.root);
|
||||
cols.hidden = true;
|
||||
actions.hidden = true;
|
||||
editHost.hidden = false;
|
||||
view.start();
|
||||
}
|
||||
/** [뒤로] — 선택지로 돌아와 고른 항목을 다시 읽음(고친 값이 카드에 보임) */
|
||||
function closeEdit(): void {
|
||||
editView?.destroy();
|
||||
editView = null;
|
||||
editHost.hidden = true;
|
||||
cols.hidden = false;
|
||||
actions.hidden = false;
|
||||
detailView.reload();
|
||||
}
|
||||
|
||||
close = openModal({
|
||||
title: t("M02_PickerTitle"),
|
||||
closeLabel: t("M02_Close"),
|
||||
dialogClass: "ui-item-picker",
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소
|
||||
beforeClose: () => {
|
||||
// 닫기 단추 · Esc · 바깥 누르기 = 취소 — 편집 중 저장 안 한 고침이 있으면 물음
|
||||
beforeClose: async () => {
|
||||
if (editView && !(await editView.leave())) return false;
|
||||
editView?.destroy();
|
||||
resolve({ action: "cancel" });
|
||||
return true;
|
||||
},
|
||||
mount: (body) => {
|
||||
body.append(
|
||||
el("div", {
|
||||
className: "ui-item-picker__cols",
|
||||
children: [
|
||||
el("div", { className: "ui-item-picker__left", children: [search.root, treeHost] }),
|
||||
detail,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-item-picker__actions",
|
||||
children: [copyButton, manageButton, clearButton, applyButton],
|
||||
}),
|
||||
);
|
||||
body.append(cols, editHost, actions);
|
||||
paintDetail();
|
||||
void load(null);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_detail.ts
|
||||
* 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 ·
|
||||
* 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_InputsCard`)을 늦게 받아 씀
|
||||
* (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`).
|
||||
*
|
||||
* 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만).
|
||||
* [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집).
|
||||
* 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `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 {
|
||||
InputsCardHandle,
|
||||
LinkDoc,
|
||||
ValueDoc,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import {
|
||||
folderLines,
|
||||
itemName,
|
||||
layerOf,
|
||||
rowTip,
|
||||
shown,
|
||||
type PickerLayer,
|
||||
type PickerRow,
|
||||
} from "./ui_template_item_picker_data";
|
||||
import {
|
||||
fakeLink,
|
||||
folderOf,
|
||||
loadInputs,
|
||||
PART_OF,
|
||||
projectItems,
|
||||
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;
|
||||
layerLabels: Record<PickerLayer, string>;
|
||||
hint: string;
|
||||
unit: string;
|
||||
onEdit: (request: EditRequest) => void;
|
||||
}
|
||||
|
||||
export interface ItemDetail {
|
||||
root: HTMLElement;
|
||||
show: (row: PickerRow | null) => void;
|
||||
/** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
importEdit: "프로젝트로 가져와 고침",
|
||||
notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음",
|
||||
imported: "프로젝트에 가져옴 {value}",
|
||||
changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로",
|
||||
refresh: "새로 가져오기",
|
||||
refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?",
|
||||
importFailed: "프로젝트로 가져오지 못함 — {value}",
|
||||
refreshFailed: "새로 가져오지 못함 — {value}",
|
||||
listFailed: "프로젝트 항목을 못 읽음 — {value}",
|
||||
fake: "임시 저장(서버 길 준비 중)",
|
||||
loading: "읽는 중…",
|
||||
noCopy: "복사본에 없음",
|
||||
combo: "자재조합",
|
||||
price: "일위대가",
|
||||
};
|
||||
|
||||
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): 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: InputsCardHandle | null = null;
|
||||
let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() };
|
||||
let info: ProjectItemInfo | null = null;
|
||||
const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null;
|
||||
|
||||
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)]
|
||||
.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 paintStatus = async (row: PickerRow, mine: number): Promise<void> => {
|
||||
if (!items) return void (status.hidden = true);
|
||||
const nodes: (HTMLElement | string)[] = [];
|
||||
if (!info) nodes.push(TEXT.notImported);
|
||||
else {
|
||||
nodes.push(fill(TEXT.imported, dateOf(info.가져온날)));
|
||||
if (items.isFake())
|
||||
nodes.push(el("span", { className: "ui-item-picker__fake", text: TEXT.fake }));
|
||||
const changed = await items.masterChanged(row, info).catch(() => false);
|
||||
if (mine !== seq) return;
|
||||
if (changed) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
status.replaceChildren(...nodes);
|
||||
status.hidden = false;
|
||||
};
|
||||
|
||||
async function refresh(row: PickerRow): Promise<void> {
|
||||
if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return;
|
||||
try {
|
||||
await items.refresh(row, info);
|
||||
infos = null;
|
||||
show(row);
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.refreshFailed, why(error)), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */
|
||||
async function edit(kind: EditKind | "inputs"): Promise<void> {
|
||||
const row = current;
|
||||
if (!row || !items || !opts.projectId) return;
|
||||
if (!info) {
|
||||
try {
|
||||
info = await items.importItem(row);
|
||||
infos = null;
|
||||
} 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, fake } = await loadInputs(row);
|
||||
let link: LinkDoc | null = fake ? fakeLink(list) : null;
|
||||
let values: ValueDoc = {};
|
||||
if (info && items) {
|
||||
link = ((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ?? link;
|
||||
values = ((await items.read(info.폴더, "입력값")) as ValueDoc | 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_InputsCard"),
|
||||
]);
|
||||
inputs = inputModule.createInputsCard({ mode: "view", onEdit: () => void edit("inputs") });
|
||||
cards = cardModule.createItemCards({
|
||||
source: () => source,
|
||||
editable: () => !!items,
|
||||
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) 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;
|
||||
source = makeSource(row);
|
||||
await ensureCards();
|
||||
if (mine !== seq || !cards || !inputs) return;
|
||||
const label = info ? null : TEXT.importEdit;
|
||||
cards.relabel({ structure: label, area: label, material: label });
|
||||
inputs.setEditLabel(label ?? "선택 입력 수정");
|
||||
cards.root.hidden = false;
|
||||
void paintStatus(row, mine);
|
||||
void cards.load({ id: row.열, label: itemName(row) });
|
||||
void paintInputs(row, mine);
|
||||
})();
|
||||
}
|
||||
|
||||
return { root, show, reload: () => show(current) };
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_edit.ts
|
||||
* 항목 고르기 모달의 **편집 화면**(계획서 25-2d) — 카드 [수정] 이 모달 안에서 이 화면으로 바뀜.
|
||||
* 도면 = M02 CAD 부품 · 면적산출 · 재료산출 = M02 스프레드시트 · 선택 입력 = 값 받기 카드.
|
||||
* 고침은 **프로젝트 복사본**에만 씀(`ProjectItems.write`) — 마스터 · 회사 · 개인 원본은 안 바뀜.
|
||||
* [뒤로] = 선택지(나무 + 카드)로 · 저장 안 한 고침이 있으면 버릴지 물음.
|
||||
* 편집 부품은 이 화면을 열 때만 `import()`(B05 · B06 번들에 안 섞임).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
|
||||
import type { DrawingTemplateHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Drawing";
|
||||
import type {
|
||||
InputsCardHandle,
|
||||
LinkDoc,
|
||||
ValueDoc,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import type { StructureDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type { EditRequest } from "./ui_template_item_picker_detail";
|
||||
import { itemName } from "./ui_template_item_picker_data";
|
||||
import { fakeLink, loadInputs, PART_OF } from "./ui_template_item_picker_project";
|
||||
|
||||
export interface EditView {
|
||||
root: HTMLElement;
|
||||
/** 붙인 뒤 부름 — 편집 부품을 받아 세움 */
|
||||
start: () => void;
|
||||
isDirty: () => boolean;
|
||||
/** 저장 안 한 고침을 버릴지 묻고, 괜찮으면 부품을 치움 — false 면 그대로 */
|
||||
leave: () => Promise<boolean>;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
back: "← 뒤로",
|
||||
save: "저장",
|
||||
saved: "프로젝트 복사본에 저장했음",
|
||||
saveFailed: "저장하지 못함 — {value}",
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill: "면적산출 알약 없음 — 선택 입력에서 자리를 면적산출로 이으면 여기 뜸",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
material: "재료산출",
|
||||
inputs: "선택 입력",
|
||||
suffix: "{value} 수정",
|
||||
};
|
||||
|
||||
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 {
|
||||
const { kind, row, info, items } = req;
|
||||
let dirty = false;
|
||||
let saving = false;
|
||||
let doc: unknown = null;
|
||||
let drawing: DrawingTemplateHandle | null = null;
|
||||
let sheet: SpreadsheetHandle | null = null;
|
||||
let card: InputsCardHandle | null = null;
|
||||
|
||||
const word = {
|
||||
structure: TEXT.drawing,
|
||||
area: TEXT.area,
|
||||
material: TEXT.material,
|
||||
inputs: TEXT.inputs,
|
||||
}[kind];
|
||||
const back = createButton({ label: TEXT.back, variant: "ghost", size: "sm" });
|
||||
const save = createButton({ label: TEXT.save, variant: "filled", size: "sm" });
|
||||
save.disabled = true;
|
||||
const note = el("span", { className: "ui-item-picker__edit-note" });
|
||||
const body = el("div", { className: "ui-item-picker__edit-body" });
|
||||
const root = el("div", {
|
||||
className: "ui-item-picker__edit",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-item-picker__edit-top",
|
||||
children: [
|
||||
back,
|
||||
el("h4", {
|
||||
className: "ui-item-picker__edit-title",
|
||||
text: `${itemName(row)} — ${fill(TEXT.suffix, word)}`,
|
||||
}),
|
||||
note,
|
||||
save,
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
});
|
||||
|
||||
const setDirty = (next: boolean): void => {
|
||||
dirty = next;
|
||||
save.disabled = !next || saving;
|
||||
};
|
||||
const fail = (text: string): void => {
|
||||
body.replaceChildren(el("p", { className: "ui-item-picker__hint", text }));
|
||||
};
|
||||
|
||||
/** 면적산출 알약 — 항목 연결에서 자리가 「면적」 인 입력(25-1b) */
|
||||
async function areaLinks(): Promise<{ links: LinkOptions; notice?: string }> {
|
||||
const { list, fake } = await loadInputs(row);
|
||||
const link =
|
||||
((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ??
|
||||
(fake ? fakeLink(list) : null);
|
||||
const 항목 = (link?.줄 ?? [])
|
||||
.filter((one) => one.자리 === "면적")
|
||||
.map((one) => ({
|
||||
키: one.알약 ?? `입력|${one.로직}|${one.입력}`,
|
||||
이름: one.입력,
|
||||
단위:
|
||||
list.로직.find((logic) => logic.로직 === one.로직)?.입력.find((x) => x.이름 === one.입력)
|
||||
?.단위 ?? "",
|
||||
}));
|
||||
return 항목.length
|
||||
? { links: { 항목, 모드: "입력" } }
|
||||
: { links: { 항목: [], 모드: "입력" }, notice: TEXT.noAreaPill };
|
||||
}
|
||||
|
||||
async function mountSheet(kindOf: "area" | "material"): Promise<void> {
|
||||
const [{ createSpreadsheet }, pills, { searchMaterials }] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
kindOf === "area"
|
||||
? areaLinks()
|
||||
: Promise.resolve({ links: undefined as LinkOptions | undefined, notice: undefined }),
|
||||
import("../M01_MasterData/M01_MasterData_Api_Fetch"),
|
||||
]);
|
||||
const links: LinkOptions = pills.links ?? { 항목: [], 모드: "자재", 자재찾기: searchMaterials };
|
||||
if (pills.notice) note.textContent = pills.notice;
|
||||
const host = el("div", { className: "ui-item-picker__edit-sheet" });
|
||||
body.replaceChildren(host);
|
||||
sheet = createSpreadsheet(host, doc as Workbook, {
|
||||
readOnly: false,
|
||||
tabs: false,
|
||||
onChange: () => setDirty(true),
|
||||
links,
|
||||
});
|
||||
}
|
||||
|
||||
async function mountDrawing(): Promise<void> {
|
||||
const { mountDrawingTemplate } =
|
||||
await import("../M02_MasterTemplete/M02_MasterTemplete_Drawing");
|
||||
const host = el("div", { className: "ui-item-picker__edit-cad" });
|
||||
body.replaceChildren(host);
|
||||
drawing = mountDrawingTemplate(host, (doc as StructureDoc).도면 as never, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly: false,
|
||||
name: `item:${info.폴더}`,
|
||||
layer: "project",
|
||||
projectId: req.projectId,
|
||||
onChanged: (changed) => setDirty(changed),
|
||||
originMarker: true,
|
||||
noArea: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mountInputs(): Promise<void> {
|
||||
const [{ createInputsCard }, loaded] = await Promise.all([
|
||||
import("../M02_MasterTemplete/M02_MasterTemplete_InputsCard"),
|
||||
loadInputs(row),
|
||||
]);
|
||||
const link =
|
||||
((await items.read(info.폴더, "입력연결")) as LinkDoc | null) ??
|
||||
(loaded.fake ? fakeLink(loaded.list) : null);
|
||||
const values = ((await items.read(info.폴더, "입력값")) as ValueDoc | null) ?? {};
|
||||
card = createInputsCard({ mode: "value", onChange: () => setDirty(true) });
|
||||
card.set({ list: loaded.list, link, values });
|
||||
body.replaceChildren(
|
||||
el("div", { className: "ui-item-picker__edit-inputs", children: [card.root] }),
|
||||
);
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
try {
|
||||
if (kind !== "inputs") {
|
||||
doc = await items.read(info.폴더, PART_OF[kind]);
|
||||
if (doc == null) return fail(fill(TEXT.loadFailed, "복사본에 없음"));
|
||||
}
|
||||
if (kind === "structure") await mountDrawing();
|
||||
else if (kind === "inputs") await mountInputs();
|
||||
else await mountSheet(kind);
|
||||
} catch (error) {
|
||||
fail(fill(TEXT.loadFailed, why(error)));
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
save.disabled = true;
|
||||
try {
|
||||
if (kind === "inputs") {
|
||||
if (!card?.isValid()) return void showToast(TEXT.outOfRange, "error");
|
||||
await items.write(info.폴더, "입력값", card.getValues());
|
||||
} else if (kind === "structure") {
|
||||
const edited = await drawing?.getDoc();
|
||||
await items.write(info.폴더, "도면", { ...(doc as StructureDoc), 도면: edited });
|
||||
} else if (sheet) {
|
||||
// 번호 칸은 스프레드시트가 모름 — 읽은 문서 위에 편집본을 얹음(M02 저장과 같은 규칙)
|
||||
const merged = { ...(doc as Record<string, unknown>), ...sheet.getDoc() };
|
||||
await items.write(info.폴더, PART_OF[kind], merged);
|
||||
doc = merged;
|
||||
}
|
||||
setDirty(false);
|
||||
showToast(TEXT.saved, "success");
|
||||
} catch (error) {
|
||||
showToast(fill(TEXT.saveFailed, why(error)), "error");
|
||||
} finally {
|
||||
saving = false;
|
||||
save.disabled = !dirty;
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = (): void => {
|
||||
drawing?.destroy();
|
||||
sheet?.destroy();
|
||||
drawing = null;
|
||||
sheet = null;
|
||||
root.remove();
|
||||
};
|
||||
const leave = async (): Promise<boolean> => !dirty || showConfirmDialog(TEXT.discard);
|
||||
|
||||
save.addEventListener("click", () => void doSave());
|
||||
back.addEventListener("click", async () => {
|
||||
if (await leave()) onBack();
|
||||
});
|
||||
|
||||
return { root, start: () => void start(), isDirty: () => dirty, leave, destroy };
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* ui_template_item_picker_project.ts
|
||||
* 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 원본 층 · 입력 목록을 한 곳에서 부름.
|
||||
*
|
||||
* 프로젝트 복사본 GET /api/projects/{id}/items 목록
|
||||
* GET|PUT /api/projects/{id}/items/{폴더}/{부분} 읽기 · 고침(sub6 · 25-1b 약속)
|
||||
* POST /api/projects/{id}/items 미리 가져오기(body = 원본 참조)
|
||||
* POST /api/projects/{id}/items/{폴더}/refresh 새로 가져오기
|
||||
* 입력 목록 GET /api/m02/layers/{층}/items/{키}/inputs?owner= (sub4 · 25-2a)
|
||||
*
|
||||
* ⚠ 이음 자리 — 위 길이 서버에 아직 없으면(`{"detail":"Not Found"}`) **약속 모양의 가짜 응답**으로 동작함:
|
||||
* 복사본 = 브라우저 저장소(`aislo.itemsFake.{프로젝트}`)에 원본을 복사해 두고 거기서 읽고 고침 ·
|
||||
* 입력 목록 = M01 조합 · 로직 길로 짜 맞춤 · 항목 연결 = 「모든 입력이 선택」(가짜 기본값).
|
||||
* 실서버가 오면 같은 함수가 실서버 응답을 그대로 씀(가짜 분기는 길이 있으면 안 탐) — `FAKE` 로 찾음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readTemplate, type Kind } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
|
||||
import type {
|
||||
InputsList,
|
||||
LinkDoc,
|
||||
LogicInput,
|
||||
} from "../M02_MasterTemplete/M02_MasterTemplete_InputsCard";
|
||||
import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data";
|
||||
|
||||
/** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */
|
||||
export type ProjectPart =
|
||||
"도면" | "면적" | "재료" | "자재조합" | "입력연결" | "입력값" | "고른자재";
|
||||
|
||||
/** 원본 종류 → 복사본 부분 */
|
||||
export const PART_OF: Record<"structure" | "area" | "material", ProjectPart> = {
|
||||
structure: "도면",
|
||||
area: "면적",
|
||||
material: "재료",
|
||||
};
|
||||
|
||||
export interface ProjectItemInfo {
|
||||
폴더: string;
|
||||
원본: { layer: string; owner: string | null; key: string; name?: string };
|
||||
원본판?: string;
|
||||
가져온날?: string;
|
||||
/** 원본(마스터 · 회사 · 개인)이 가져온 뒤 바뀜 */
|
||||
마스터바뀜?: boolean;
|
||||
}
|
||||
|
||||
/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */
|
||||
export const folderOf = (row: PickerRow): string =>
|
||||
`${layerOf(row)}_${(ownerOf(row) ?? "none").replace("/", "-")}_${row.key}`;
|
||||
|
||||
interface Reply {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
const reasonOf = (reply: Reply): string => {
|
||||
const detail = (reply.body as { detail?: unknown } | null)?.detail;
|
||||
return typeof detail === "string" && detail ? detail : `HTTP ${reply.status}`;
|
||||
};
|
||||
|
||||
async function call(path: string, init: RequestInit = {}): Promise<Reply> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: init.body ? { "Content-Type": "application/json" } : undefined,
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => null) };
|
||||
}
|
||||
|
||||
/** 길 자체가 없음(서버가 아직 안 만듦) — 「문서가 없음」의 404 와 갈라 봄 */
|
||||
const routeMissing = (reply: Reply): boolean =>
|
||||
reply.status === 404 && (reply.body as { detail?: unknown } | null)?.detail === "Not Found";
|
||||
|
||||
const send = (body: unknown): RequestInit => ({ method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
/* ── 가짜 복사본(FAKE) — 브라우저 저장소 ─────────────────────────────────────── */
|
||||
interface FakeItem {
|
||||
info: ProjectItemInfo;
|
||||
parts: Partial<Record<ProjectPart, unknown>>;
|
||||
}
|
||||
type FakeDb = Record<string, FakeItem>;
|
||||
const memory = new Map<string, FakeDb>();
|
||||
const storeKey = (projectId: string): string => `aislo.itemsFake.${projectId}`;
|
||||
|
||||
function fakeLoad(projectId: string): FakeDb {
|
||||
const kept = memory.get(projectId);
|
||||
if (kept) return kept;
|
||||
try {
|
||||
const raw = localStorage.getItem(storeKey(projectId));
|
||||
const db = raw ? (JSON.parse(raw) as FakeDb) : {};
|
||||
memory.set(projectId, db);
|
||||
return db;
|
||||
} catch {
|
||||
const db: FakeDb = {};
|
||||
memory.set(projectId, db);
|
||||
return db;
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSave(projectId: string): void {
|
||||
try {
|
||||
localStorage.setItem(storeKey(projectId), JSON.stringify(fakeLoad(projectId)));
|
||||
} catch {
|
||||
// 저장소가 꽉 차도 이 탭에서는 메모리 값으로 계속 됨
|
||||
}
|
||||
}
|
||||
|
||||
/** 원본 층에서 도면 · 면적 · 재료 문서를 읽고 판을 이은 글 — 복사본 `원본판` */
|
||||
async function readOriginals(
|
||||
row: PickerRow,
|
||||
): Promise<{ parts: FakeItem["parts"]; version: string }> {
|
||||
const kinds: ("structure" | "area" | "material")[] = ["structure", "area", "material"];
|
||||
const got = await Promise.all(
|
||||
kinds.map((kind) => readTemplate(layerOf(row), kind as Kind, row.열, null).catch(() => null)),
|
||||
);
|
||||
const parts: FakeItem["parts"] = {};
|
||||
kinds.forEach((kind, index) => {
|
||||
if (got[index]) parts[PART_OF[kind]] = structuredClone(got[index]!.문서);
|
||||
});
|
||||
return { parts, version: got.map((one) => one?.판 ?? "").join("|") };
|
||||
}
|
||||
|
||||
export interface ProjectItems {
|
||||
/** 프로젝트에 가져온 항목 목록 */
|
||||
list: () => Promise<ProjectItemInfo[]>;
|
||||
/** 복사본 부분 하나 — 없으면 null */
|
||||
read: (folder: string, part: ProjectPart) => Promise<unknown | null>;
|
||||
/** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판 …) */
|
||||
write: (folder: string, part: ProjectPart, doc: unknown) => Promise<void>;
|
||||
/** 프로젝트로 미리 가져옴 */
|
||||
importItem: (row: PickerRow) => Promise<ProjectItemInfo>;
|
||||
/** 새로 가져옴 — 복사본을 원본으로 덮음 */
|
||||
refresh: (row: PickerRow, info: ProjectItemInfo) => Promise<ProjectItemInfo>;
|
||||
/** 가져온 뒤 원본이 바뀌었나 */
|
||||
masterChanged: (row: PickerRow, info: ProjectItemInfo) => Promise<boolean>;
|
||||
/** 가짜로 동작 중인가(서버 길이 아직 없음) */
|
||||
isFake: () => boolean;
|
||||
}
|
||||
|
||||
const clients = new Map<string, ProjectItems>();
|
||||
|
||||
/** 프로젝트 하나의 창구 — 같은 프로젝트는 한 벌(가짜 판정 · 목록 재사용) */
|
||||
export function projectItems(projectId: string): ProjectItems {
|
||||
const kept = clients.get(projectId);
|
||||
if (kept) return kept;
|
||||
let fake = false;
|
||||
const base = `/projects/${enc(projectId)}/items`;
|
||||
|
||||
const client: ProjectItems = {
|
||||
isFake: () => fake,
|
||||
list: async () => {
|
||||
if (!fake) {
|
||||
const reply = await call(base);
|
||||
if (reply.status === 200 && Array.isArray(reply.body))
|
||||
return reply.body as ProjectItemInfo[];
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
fake = true; // FAKE — 서버 길이 아직 없음
|
||||
}
|
||||
return Object.values(fakeLoad(projectId)).map((item) => item.info);
|
||||
},
|
||||
read: async (folder, part) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(folder)}/${enc(part)}`);
|
||||
if (reply.status === 200) return reply.body;
|
||||
if (reply.status === 404) return null;
|
||||
throw new Error(reasonOf(reply));
|
||||
}
|
||||
return fakeLoad(projectId)[folder]?.parts[part] ?? null;
|
||||
},
|
||||
write: async (folder, part, doc) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(folder)}/${enc(part)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
return;
|
||||
}
|
||||
const item = fakeLoad(projectId)[folder];
|
||||
if (!item) throw new Error("프로젝트에 가져오지 않은 항목");
|
||||
item.parts[part] = structuredClone(doc);
|
||||
fakeSave(projectId);
|
||||
},
|
||||
importItem: async (row) => {
|
||||
const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key };
|
||||
if (!fake) {
|
||||
const reply = await call(base, send(ref));
|
||||
if (reply.status < 300) return reply.body as ProjectItemInfo;
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
fake = true; // FAKE
|
||||
}
|
||||
const { parts, version } = await readOriginals(row);
|
||||
const info: ProjectItemInfo = {
|
||||
폴더: folderOf(row),
|
||||
원본: { ...ref, name: row.종류 },
|
||||
원본판: version,
|
||||
가져온날: new Date().toISOString(),
|
||||
마스터바뀜: false,
|
||||
};
|
||||
fakeLoad(projectId)[info.폴더] = { info, parts };
|
||||
fakeSave(projectId);
|
||||
return info;
|
||||
},
|
||||
refresh: async (row, info) => {
|
||||
if (!fake) {
|
||||
const reply = await call(`${base}/${enc(info.폴더)}/refresh`, send({}));
|
||||
if (reply.status >= 400) throw new Error(reasonOf(reply));
|
||||
return reply.body as ProjectItemInfo;
|
||||
}
|
||||
delete fakeLoad(projectId)[info.폴더];
|
||||
return client.importItem(row);
|
||||
},
|
||||
masterChanged: async (row, info) => {
|
||||
if (!fake) return info.마스터바뀜 === true;
|
||||
return (await readOriginals(row)).version !== (info.원본판 ?? "");
|
||||
},
|
||||
};
|
||||
clients.set(projectId, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/* ── 입력 목록(원본 층 기준) — 25-1b 약속 길 · 없으면 FAKE ─────────────────────── */
|
||||
const inputsCache = new Map<string, Promise<{ list: InputsList; fake: boolean }>>();
|
||||
|
||||
async function fakeInputs(row: PickerRow): Promise<InputsList> {
|
||||
const combo = row.일위대가 ?? "";
|
||||
const got = await call(`/m01/combo?key=${enc(combo)}`);
|
||||
const header = (got.body as { combo?: { 이름?: string; 담은로직?: { 로직: string }[] } } | null)
|
||||
?.combo;
|
||||
if (got.status !== 200 || !header) throw new Error(reasonOf(got));
|
||||
const list: InputsList = { 일위대가: combo, 이름: header.이름 ?? combo, 로직: [], 빠진로직: [] };
|
||||
for (const one of header.담은로직 ?? []) {
|
||||
const [logic, auto] = await Promise.all([
|
||||
call(`/m01/logic?key=${enc(one.로직)}`),
|
||||
call(`/m01/logic/auto?key=${enc(one.로직)}`),
|
||||
]);
|
||||
const row = (
|
||||
logic.body as {
|
||||
logic?: { 이름?: string; 결과단위?: string; 입력?: (LogicInput & { 비고?: string })[] };
|
||||
} | null
|
||||
)?.logic;
|
||||
if (logic.status !== 200 || !row) {
|
||||
list.빠진로직.push(one.로직);
|
||||
continue;
|
||||
}
|
||||
const values = (auto.body as { 값?: Record<string, string | number> } | null)?.값 ?? {};
|
||||
list.로직.push({
|
||||
로직: one.로직,
|
||||
이름: row.이름 ?? one.로직,
|
||||
결과단위: row.결과단위 ?? null,
|
||||
입력: (row.입력 ?? []).map((input) => ({ ...input, 자동값: values[input.이름] ?? null })),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 원본 항목의 입력 목록 — 25-2a 길 → 없으면 가짜 */
|
||||
export function loadInputs(row: PickerRow): Promise<{ list: InputsList; fake: boolean }> {
|
||||
const id = `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`;
|
||||
const kept = inputsCache.get(id);
|
||||
if (kept) return kept;
|
||||
const made = (async () => {
|
||||
const owner = ownerOf(row);
|
||||
const reply = await call(
|
||||
`/m02/layers/${layerOf(row)}/items/${enc(row.key)}/inputs${owner ? `?owner=${enc(owner)}` : ""}`,
|
||||
);
|
||||
if (reply.status === 200) return { list: reply.body as InputsList, fake: false };
|
||||
if (!routeMissing(reply)) throw new Error(reasonOf(reply));
|
||||
return { list: await fakeInputs(row), fake: true }; // FAKE
|
||||
})();
|
||||
inputsCache.set(id, made);
|
||||
made.catch(() => inputsCache.delete(id)); // 못 받으면 다음에 다시
|
||||
return made;
|
||||
}
|
||||
|
||||
/** 가짜 항목 연결 — 모든 입력이 「선택」(실서버가 오면 그 파일을 씀) */
|
||||
export function fakeLink(list: InputsList): LinkDoc {
|
||||
return {
|
||||
판: "fake",
|
||||
줄: list.로직.flatMap((logic) =>
|
||||
logic.입력.map((input) => ({ 로직: logic.로직, 입력: input.이름, 자리: "선택" as const })),
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user