Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Hopyo_MatPick.ts
T
eomsangdonandClaude Opus 5.5 43e9455688 feat(828-1): 원파일 일위대가 구성 줄 → 일위대가 길 · 중기 구성 → 내 기계 씨앗 · 낙찰률 줄 2 칸 좁힘
- ㉯ STC 일위 = HP 호표(엑셀 일위대가표 길과 같은 꼴 · 「일위대가표」 시트) → 목록 · 만들기 · 고를 재료 판(구성 줄 · 「일위대가 N」)
- ㉰ 구성표 중기 → 기계구성(엑셀 중기사용료와 같은 값 · 달러 손료 = 천원) · 같은 파일 구성 대조 같은구성 → STmate 기계 찾기
- ㉱ 낙찰률 줄 2 칸(원단가) = 구성 셈 · 일위 머리만(엑셀 일위대가표와 같음) · 산근 · 자원 = 1 칸 그대로(엑셀 단가산출근거와 같음 · 812-1 짝 0)
- 별도계상 = 자재 이름 · 규격 「별도계상」 · 「별산」(엑셀 비고 「별산…」 짝 · 828-2 대조)
- MyMachine 엑셀 중기사용료 잡품 줄 = 잡재료 · 잡재료비 이름도(원파일과 같은 값)
- 시험 test_828_1_stc_composition 새로 · test_814_1 낡은 칸 고침(원파일 호표 0 → 45 · 격자 일위대가표 시트) · test_810_1 거울 대조 글 칸 앞 빈칸 걷음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:47:55 +09:00

244 lines
9.9 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[];
/** 일위대가 구성 줄이 있는 호표(828-1 · 코드 숫자) */
호표?: 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 hp = k.호표?.length ? ` · 일위대가 ${k.호표.length}` : "";
const sub = [k.방법, `${k.블록.length} 블록${hp} · ${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(" · ")}${hp && ` · 호표 ${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>;