- test_item_picker_detail: 복사본 창구(가짜 · 약속 길 · 403 까닭) · 입력 목록 · 카드 순수 함수 · 한 벌 지킴 · 원본 층에 안 씀 - 자동값 · 확인 표시 글자 대비 4.5 이상 · 고르기 값은 원래 모양(숫자)으로 저장 - 새로 가져오기 = 원본에서 온 문서만 덮고 입력값은 남김 - 모달 폭 두 클래스로(공용 모달 폭에 안 밀림) · CAD · 시트 편집 높이 채움 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* 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(/\//g, "-")}_${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;
|
|
}
|
|
// 원본에서 온 문서(도면 · 면적 · 재료)만 새로 덮음 — 사용자가 넣은 값(입력값 · 고른자재)은 그대로
|
|
const kept = fakeLoad(projectId)[info.폴더]?.parts;
|
|
const fresh = await client.importItem(row);
|
|
const item = fakeLoad(projectId)[fresh.폴더];
|
|
for (const part of ["입력값", "고른자재"] as const)
|
|
if (kept?.[part] !== undefined && item) item.parts[part] = kept[part];
|
|
fakeSave(projectId);
|
|
return fresh;
|
|
},
|
|
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 })),
|
|
),
|
|
};
|
|
}
|