feat(M02): 로직 고르기 창 한 목록 — 찾기 단추 · 탭 없앰 · 구분 칸 · 치는 대로 걸러짐 (51-3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-05 19:35:01 +09:00
co-authored by Claude Sonnet 5.5
parent c45bf3460e
commit 9aeefd44dc
3 changed files with 269 additions and 111 deletions
@@ -1,8 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Pick.ts
* [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) ·
* 탭 단가산출 · 단가승격 · 사전조합(50-3 ④) — 서고 탭은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) +
* 그 층 목록 · 고르면 참조 {층 · 주인 · 키} · 프로젝트 · 카드엔 서고 탭 없음
* [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) ·
* 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} ·
* 프로젝트 · 카드엔 서고 줄 없음
* ========================================================================== */
import {
@@ -65,9 +65,39 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
detail.select.disabled = true;
const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") });
const list = el("div", { className: "m01-logic__pick-list" });
const hint = el("p", { className: "m01-logic__muted", text: tc("Pick_Hint") });
const hint = el("p", { className: "m01-logic__muted" });
const close = (): void => backdrop.remove();
/* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let scopes: Scope[] = [];
let scope: Scope | null = null;
let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = [];
let loading = false;
let seq = 0;
const pickRow = (
key: string,
kind: string,
title: string,
sub2: string,
unit: string,
): HTMLElement =>
el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": key },
children: [
el("span", { className: "m01-logic__muted", attrs: { "data-pick-kind": "" }, text: kind }),
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: title }),
el("span", { className: "m01-logic__muted", text: sub2 }),
],
}),
el("span", { text: unit }),
],
});
const find = (): void => {
const q = searchField.input.value.trim().toLowerCase();
const found = all.filter(
@@ -76,96 +106,68 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
(!detail.select.value || x.상세구분 === detail.select.value) &&
(!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)),
);
hint.textContent = found.length ? `${found.length} / ${all.length}` : tc("Pick_None");
const libFound = sub.select.value
? []
: libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q));
const total = all.length + libRows.length;
const shown = found.length + libFound.length;
hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None");
list.replaceChildren(
...found.slice(0, 100).map((x) => {
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": x.키 },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: x.이름 }),
el("span", {
className: "m01-logic__muted",
text: `${x.키} · ${x.구분} ${x.상세구분}`.trim(),
}),
],
}),
el("span", { text: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}` }),
],
});
const row = pickRow(
x.키,
tc("Kind_Logic"),
x.이름,
`${x.키} · ${x.구분} ${x.상세구분}`.trim(),
`${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`,
);
row.addEventListener("click", () => {
close();
done(x);
});
return row;
}),
);
};
sub.select.addEventListener("change", () => {
const d = subs.find((s) => s.name === sub.select.value)?.details ?? [];
detail.setOptions(opts(d), "");
detail.select.disabled = d.length === 0;
});
/* 서고 탭(50-3 ④) — 탭 · 층 단추 · 목록 */
const filters = el("div", {
className: "m01-logic__filters",
children: [
sub.root,
detail.root,
searchField.root,
createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }),
],
});
const tabs = el("div", { className: "ui-btn-group", attrs: { "data-pick-tabs": "" } });
const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } });
let tab: "logic" | "promote" | "precombo" = "logic";
let scopes: Scope[] = [];
let scope: Scope | null = null;
let seq = 0;
const libList = async (): Promise<void> => {
if (!lib || !scope || tab === "logic") return;
const mine = ++seq;
const at = scope;
list.replaceChildren();
hint.textContent = tc("Loading");
let rows: LibraryRow[] = [];
try {
rows = await fetchLibrary(at, tab);
} catch (error) {
if (mine === seq)
hint.textContent = error instanceof Error ? error.message : tc("Load_Failed");
return;
}
if (mine !== seq) return;
hint.textContent = rows.length ? `${rows.length}` : tc("Pick_None");
list.replaceChildren(
...rows.map((x) => {
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button", "data-pick": x.이름 },
children: [
el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: x.제목 || x.이름 }),
el("span", { className: "m01-logic__muted", text: x.이름 }),
],
}),
el("span", { text: x.단위 ?? "" }),
],
});
...libFound.map((x) => {
const at = scope as Scope;
const row = pickRow(
x.row.이름,
tc(x.kind),
x.row.제목 || x.row.이름,
x.row.이름,
x.row.단위 ?? "",
);
row.addEventListener("click", () => {
close();
lib.pick({ ...at, 키: x.이름 }, x.제목 || x.이름);
lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름);
});
return row;
}),
);
};
const loadLib = async (): Promise<void> => {
if (!lib || !scope) return;
const mine = ++seq;
const at = scope;
loading = true;
find();
try {
const [promote, combo] = await Promise.all([
fetchLibrary(at, "promote"),
fetchLibrary(at, "precombo"),
]);
if (mine !== seq) return;
libRows = [
...promote.map((row) => ({ kind: "Kind_Promote" as const, row })),
...combo.map((row) => ({ kind: "Kind_Combo" as const, row })),
];
} catch (error) {
if (mine !== seq) return;
libRows = [];
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
loading = false;
find();
};
const drawLayers = (): void =>
layerBar.replaceChildren(
...scopes.map((x) =>
@@ -175,41 +177,33 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
onClick: () => {
scope = x;
drawLayers();
void libList();
void loadLib();
},
}),
),
);
const drawTabs = (): void => {
tabs.replaceChildren(
...(["logic", "promote", "precombo"] as const).map((k) =>
createButton({
label: tc(
k === "logic" ? "Kind_Logic" : k === "promote" ? "Kind_Promote" : "Lib_Precombo",
),
variant: k === tab ? "filled" : "ghost",
onClick: () => {
tab = k;
drawTabs();
filters.style.display = k === "logic" ? "" : "none"; // 공용 꼴 display 가 hidden 을 이김
layerBar.style.display = k === "logic" ? "none" : "";
if (k === "logic") {
list.replaceChildren();
hint.textContent = tc("Pick_Hint");
} else void libList();
},
}),
),
);
};
sub.select.addEventListener("change", () => {
const d = subs.find((s) => s.name === sub.select.value)?.details ?? [];
detail.setOptions(opts(d), "");
detail.select.disabled = d.length === 0;
find();
});
detail.select.addEventListener("change", find);
searchField.input.addEventListener("input", find);
const filters = el("div", {
className: "m01-logic__filters",
children: [sub.root, detail.root, searchField.root],
});
layerBar.style.display = "none";
if (lib)
void upper(lib.layer, lib.owner).then((got) => {
scopes = got;
scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층
if (!got.length) return;
drawTabs();
layerBar.style.display = "";
drawLayers();
void loadLib();
});
const dialog = el("div", {
@@ -223,7 +217,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
tabs,
filters,
layerBar,
hint,
@@ -234,13 +227,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
document.body.append(backdrop);
searchField.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find());
searchField.input.focus();
void Promise.all([fetchLogicSubs(), fetchLogics()])
.then(([s, l]) => {
subs.push(...s);
all = l;
find();
sub.setOptions(opts(s.map((x) => x.name)), "");
})
.catch((error) =>
@@ -45,11 +45,6 @@ const TEXT = {
Open: ["열기", "Open"],
Pick_Title: ["로직 고르기", "Pick a logic"],
Pick_Search: ["이름·키로 찾기", "Search by name or key"],
Pick_Find: ["찾기", "Find"],
Pick_Hint: [
"구분 → 상세구분을 고르고 [찾기]를 누름 — 하나씩 직접 고름",
"Choose category, detail, then Find — pick one by one",
],
Pick_None: ["찾은 로직 없음", "No logics found"],
Pick_Close: ["닫기", "Close"],
Pick_Blocked: ["막힘", "Blocked"],
@@ -92,7 +87,6 @@ const TEXT = {
Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"],
Labor_LineBase: ["기준", "Base"],
Labor_FromLogic: ["원 로직", "Source logic"],
Lib_Precombo: ["사전조합", "Pre-combination"],
Lib_System: ["마스터", "Master"],
Lib_Company: ["회사", "Company"],
Lib_Personal: ["개인", "Personal"],