Files
Aislo/ui_template/ui_template_item_picker_project.ts
T
eomsangdonandClaude Sonnet 5.5 d94f3f78c1 feat(ui_template): 항목 고르기 모달 — 시험 · 글자 대비 · 새로 가져올 때 넣은 값 유지 · 편집 화면 높이 (PLAN 25-2d)
- 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
2026-09-29 23:49:43 +09:00

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 })),
),
};
}