Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts
T
eomsangdonandClaude Opus 5.5 520bb33909 feat(827-3): 불러오기 재료 걸음 3 — 고를 재료 판 · 그 파일 안 고름(별칭 아님) · SK 상세 같은 파일 물음
- 새 M02_San_MatPick: 종 열쇠(이름 · 규격 · 단위 · 단서) · 고름 자리 = 개인 층 실무자료/재료고름/<파일 지문>.json([끝내기] 뒤 남음 · 같은 파일 다시 올리면 그대로) · 판(블록마다 재료찾기 판정 → 종 · 첫 판 한 번 셈 → 산근 저장본 칸) · 만들 때 얹기 · 이미 만든 SK 얹기(가져온 그대로인 줄만 · 바꿈 · 따로 고른 줄 그대로 · 풀면 가져온 그대로)
- 만들기: from-hopyo 가 그 파일 고름째 만듦(저장 안 한 판 고름 = 만들 때 저장) · 원본 지문 · 고른 줄 = 이음 + 재료찾기.고름 · 문서 셈 우리 값(엑셀 값 기억 없음) · 별도계상 = 품목만(줄 이음 · 단가 0 그대로)
- 문서 머리 가져온.재료(판정 · 후보 · 단서 · 처음 연결) · 가져온.지문
- 길: GET/POST hopyo-materials/{HP}(판 · [저장] · 만든 것 적용은 물음 예일 때만) · GET/POST material-picks/{지문}(SK 상세 물음)
- 화면: 단가산출 목록 위 「고를 재료」 판(후보 고름 · 찾기 · 고름 풀기 · n 블록에 적용 · 이어진 재료 접힘 · [저장]) · 저장 안 한 고름 = 떠날 때 물음 · 줄 쌓기 재료 줄 판정 배지 · 풍선말(판정 · 단서 · 후보) · 이름 단추 · [이음 고르기] 뒤 「같은 파일 n 줄」 물음
- 별도계상 설명 = 「별도계상 · 품목: 이름 규격」(엔진 이은 품목은 자재표 이름) — test_827_2 기대 글 고침(품목 이음 → 품목 이름)
- 시험 test_827_3 셋(파일 고름 블록 둘 · 다른 파일 0 · 별칭 · 마스터 그대로 · 풀기 · 다시 올림 유지 · 물음 예일 때만 · 따로 바꾼 줄 보존 · 별도계상 품목 + 단가 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:51:04 +09:00

241 lines
9.7 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Hopyo_MatPick.ts
* 불러오기 「고를 재료」 판(PLAN 827-3 · 요구 811-2 · 812-1) — 단가산출 목록(`Hopyo_SheetSk`) 머리에 붙음.
* · 그 파일 재료 줄을 종(이름 · 규격 · 단위 · 단서)으로 묶어 판정 · 쓰는 블록 수 · 단서 글 · 후보(엔진 차례 · 1 순위)
* = 서버 `…/hopyo-materials/{키}`(첫 판 = 블록 셈 한 번 · 그 뒤 저장본)
* · 후보 고름 · [찾기](재료 찾기 창 `openStackPick`) → 그 파일 같은 종 줄 전부 · [고름 풀기]
* · 「우리 표에 없음」 = 안 골라도 됨(가져온 값) · 이어진 재료 = 접힌 목록(방법 · 단서 — 틀린 이음도 같은 길로 바꿈)
* · 고름 = 화면 캐시 → [저장](개인 층 그 파일 자리 · 이미 만든 단가산출 n 건 적용은 물어 고를 때만) ·
* 저장 전 블록을 만들면 그 고름째 만듦(`pending` → from-hopyo 몸 · 서버가 저장) · 별칭 · 마스터에 안 씀
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { askChoice } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { MAT_PICKED } from "../A00_Common/line_stack_material";
import { openStackPick } from "../A00_Common/line_stack_pick";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
export interface MatCand {
키: string;
이름: string;
규격: string;
단위: string;
단서?: string;
}
interface MatKind {
종: string;
이름: string;
규격: string;
단위: string;
판정: string;
방법: string;
키: string | null;
후보: MatCand[];
단서: string[];
별도계상: boolean;
블록: number[];
줄: number;
고름?: MatCand;
}
interface MatBoard {
지문: string;
파일: string;
종: MatKind[];
만든: string[];
}
export type MatPicks = Record<string, MatCand | null>;
const LINKED = "이어짐";
const TONE: Record<string, BadgeTone> = { [LINKED]: "success", "우리 표에 없음": "neutral" };
const enc = encodeURIComponent;
const why = (e: unknown): string => (e instanceof Error ? e.message : String(e));
const label = (c: { 이름: string; 규격: string }): string => `${c.이름} ${c.규격}`.trim();
export function createMatPick(opt: { scope: HopyoScope; key: string; target: HopyoScope }) {
const to = `?to=${opt.target.layer}${opt.target.owner ? `&to_owner=${enc(opt.target.owner)}` : ""}`;
const url = at(opt.scope, `/hopyo-materials/${enc(opt.key)}${to}`);
const title = createCollapsibleHead({ title: "고를 재료", sub: "고른 것은 이 파일 안에서만 · 별칭 아님 · 「우리 표에 없음」은 안 골라도 됨" }); // prettier-ignore
const body = el("div", { className: "m02-hopyo-matpick__body" });
const root = el("section", {
className: "ui-collapsible m02-hopyo-matpick",
attrs: { "data-matpick": opt.key },
children: [title.root, body],
});
attachCollapsible(root);
document.addEventListener(MAT_PICKED, () => asked && root.isConnected && void load()); // SK 상세에서 같은 파일 고름
let board: MatBoard | null = null;
let asked = false;
let busy = false;
let linkedOpen = false;
const edits = new Map<string, MatCand | null>();
const now = (k: MatKind): MatCand | null =>
edits.has(k.종) ? (edits.get(k.종) ?? null) : (k.고름 ?? null);
const set = (k: MatKind, c: MatCand | null): void => {
if ((c?.키 ?? null) === (k.고름?.키 ?? null)) edits.delete(k.종);
else edits.set(k.종, c);
draw();
};
function controls(k: MatKind): HTMLElement {
const mine = now(k);
const parts: HTMLElement[] = [];
if (k.후보.length) {
const pick = createSelectField({
options: [
{ value: "", text: mine ? `고름: ${label(mine)}` : "후보에서 고름" },
...k.후보.map((c, i) => ({
value: String(i),
text: `${i ? "" : "1 순위 · "}${label(c)} ${c.단위 ? `(${c.단위})` : ""}`.trim(),
})),
],
compact: true,
size: 24,
ariaLabel: "후보에서 고름",
onChange: (v) => v && set(k, k.후보[Number(v)]),
});
pick.select.dataset.matCands = k.종;
parts.push(pick.root);
}
const find = createButton({
label: "찾기",
variant: "ghost",
size: 24,
iconStart: "search",
onClick: () =>
openStackPick(
"재료",
(p) => set(k, { 키: p.키, 이름: p.이름, 규격: p.규격 ?? "", 단위: p.단위 ?? "" }),
undefined,
k.이름,
),
});
find.dataset.matFind = k.종;
parts.push(find);
if (mine) {
const tag = createBadge(`고름: ${label(mine)} · ${k.블록.length} 블록에 적용`, "info", { size: 18 }); // prettier-ignore
tag.dataset.matPicked = k.종;
const undo = createButton({ label: "고름 풀기", variant: "ghost", size: 24, iconStart: "undo", onClick: () => set(k, null) }); // prettier-ignore
undo.dataset.matUnpick = k.종;
parts.push(tag, undo);
}
return el("div", { className: "ui-toolbar", children: parts });
}
function kindRow(k: MatKind): HTMLElement {
const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—";
const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서]
.filter(Boolean)
.join(" · ");
const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore
const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] });
if (edits.has(k.종)) box.dataset.matEdited = "";
return box;
}
function draw(): void {
if (!board) return;
const open = board.종.filter((k) => k.판정 !== LINKED);
const linked = board.종.filter((k) => k.판정 === LINKED);
title.setTitle(`고를 재료 ${open.length} · 이어진 재료 ${linked.length}`);
root.dataset.matOpen = String(open.length);
const save = createButton({
label: edits.size ? `저장(${edits.size} 종)` : "저장",
variant: "filled",
size: 24,
disabled: !edits.size || busy,
onClick: () => void persist(),
});
save.dataset.matSave = "";
const head = el("div", {
className: "ui-toolbar",
children: [save],
});
const more = el("section", {
className: `ui-collapsible${linkedOpen ? "" : " is-collapsed"}`,
attrs: { "data-mat-linked": "" },
children: [createCollapsibleHead({ title: `이어진 재료 ${linked.length}`, sub: "엔진이 이은 것 — 틀린 이음도 같은 길로 바꿈", level: 4 }).root, ...linked.map(kindRow)], // prettier-ignore
});
more.addEventListener("click", () =>
queueMicrotask(() => (linkedOpen = !more.classList.contains("is-collapsed"))),
); // 다시 그려도 펼침 그대로
body.replaceChildren(head, ...open.map(kindRow), ...(linked.length ? [more] : []));
}
async function load(): Promise<void> {
try {
board = await call<MatBoard>(url);
draw();
} catch (e) {
body.replaceChildren(createText(why(e), "error"));
}
}
async function persist(): Promise<void> {
if (!board || !edits.size) return;
const kinds = new Set(edits.keys());
const used = board.만든.length;
let apply = false;
if (used) {
const got = await askChoice({
message: `이미 만든 단가산출 ${used} 건에도 적용할까? — 가져온 그대로인 줄만 바뀜(따로 고친 줄은 그대로)`,
choices: [
{ label: "이 파일 고름만", value: "no" },
{ label: `단가산출 ${used} 건에도 적용`, value: "yes" },
],
});
if (got === null) return;
apply = got === "yes";
}
busy = true;
draw();
try {
const out = await call<{ 고침?: string[] }>(url, {
method: "POST",
body: JSON.stringify({ 고름: Object.fromEntries(edits), 만든적용: apply, 종: [...kinds] }),
});
edits.clear();
showToast(`재료 고름 저장 — ${kinds.size} 종${apply ? ` · 단가산출 ${out.고침?.length ?? 0} 건 고침` : ""}`, "success"); // prettier-ignore
} catch (e) {
showToast(why(e), "error");
} finally {
busy = false;
}
await load();
}
return {
root,
/** 처음 단가산출 탭에 올 때 한 번(첫 판 = 서버가 블록을 한 번 셈) */
ensure(): void {
if (asked) return;
asked = true;
body.replaceChildren(
createText("재료 판정 읽는 중… (처음 한 번 — 블록 수만큼 걸림)", "hint"),
);
void load();
},
/** 저장 안 한 고름 */
dirty: (): boolean => edits.size > 0,
/** 블록 만들 때 같이 보낼 저장 안 한 고름(없으면 undefined) */
pending: (): MatPicks | undefined => (edits.size ? Object.fromEntries(edits) : undefined),
/** 만들기가 고름을 저장했음 — 캐시 비우고 다시 읽음 */
reload(): void {
edits.clear();
if (asked) void load();
},
};
}
export type MatPickPanel = ReturnType<typeof createMatPick>;