feat(M01): 시험 계산 재료 · 기계 고르기를 마스터 표 모달로 — 품목 고르기 · 고름 실어 다시 계산 · 단위경고 딱지 (PLAN 8-4)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
@@ -168,6 +168,7 @@ export async function mountM01LogicLab(
|
||||
savedKey: current.origKey,
|
||||
file: current.file,
|
||||
row: current.row as LogicRow,
|
||||
prices: current.prices,
|
||||
dirty: () => current.id in drafts,
|
||||
values: current.values,
|
||||
onText: (text) => {
|
||||
|
||||
@@ -175,3 +175,25 @@
|
||||
.m01lab__edit {
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* 시험 계산 — 재료 · 기계 고르기 표 모달 */
|
||||
.m01-logic__pick--table {
|
||||
width: min(1040px, 94vw);
|
||||
}
|
||||
|
||||
.m01lab__pickrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01lab__pickrow:hover,
|
||||
.m01lab__pickrow.is-picked {
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
.m01lab__pickrow.is-picked {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01lab__warn {
|
||||
color: var(--color-warning, #b45309);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface CalcLine {
|
||||
비목: Record<string, number>;
|
||||
/** 값 없는 요소를 다른 값으로 셈 — 「준용: <직종>」 · 「산정: …」 */
|
||||
출처?: string;
|
||||
/** 자재 단위가 줄 단위와 성격이 달라 환산 못 하고 셈 — 「줄 m · 자재 Ton」 */
|
||||
단위경고?: string;
|
||||
}
|
||||
export type CalcAnswer =
|
||||
| {
|
||||
@@ -227,6 +229,8 @@ export const runCalc = (body: {
|
||||
inputs: Record<string, unknown>;
|
||||
row?: LogicRow;
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<CalcAnswer> => request("/calc", body);
|
||||
|
||||
export const runText = (body: {
|
||||
@@ -234,6 +238,8 @@ export const runText = (body: {
|
||||
inputs: Record<string, unknown>;
|
||||
row?: LogicRow;
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<TextAnswer> => request("/text", body);
|
||||
|
||||
export const saveFiles = (
|
||||
|
||||
@@ -12,9 +12,16 @@ import {
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type ElementBrief,
|
||||
type TextAnswer,
|
||||
type LogicRow,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
isMaterialKeyInput,
|
||||
machineControl,
|
||||
materialRows,
|
||||
pickBody,
|
||||
} from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
@@ -47,6 +54,8 @@ export interface CalcContext {
|
||||
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
|
||||
file?: string;
|
||||
row: LogicRow;
|
||||
/** 잡힌 품목을 보이는 데 씀 — 재료 줄의 단가 요소 · 조건 글 → 요소 */
|
||||
prices?: Record<string, ElementBrief | null>;
|
||||
dirty: () => boolean;
|
||||
/** 로직마다 넣은 값 — 다시 그려도 남음 */
|
||||
values: Record<string, string>;
|
||||
@@ -68,49 +77,56 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const fields = (ctx.row.입력 ?? []).map((spec) => {
|
||||
const name = spec.이름;
|
||||
let control: HTMLElement;
|
||||
const picks = spec.고르기;
|
||||
if (picks?.length) {
|
||||
const pick = createSelectField({
|
||||
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
value: ctx.values[name] ?? "",
|
||||
compact: true,
|
||||
onChange: (v) => {
|
||||
ctx.values[name] = v;
|
||||
},
|
||||
});
|
||||
control = pick.root;
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => {
|
||||
pick.setOptions(
|
||||
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
ctx.values[name] ?? "",
|
||||
);
|
||||
const again = (): void => host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
const fields = (ctx.row.입력 ?? [])
|
||||
.filter((spec) => !isMaterialKeyInput(spec)) // 재료 키 목록은 표 모달로 바뀌어 걷음
|
||||
.map((spec) => {
|
||||
const name = spec.이름;
|
||||
let control: HTMLElement;
|
||||
const picks = spec.고르기;
|
||||
const machine = machineControl(spec, ctx.values, again);
|
||||
if (machine) {
|
||||
control = machine;
|
||||
} else if (picks?.length) {
|
||||
const pick = createSelectField({
|
||||
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
value: ctx.values[name] ?? "",
|
||||
compact: true,
|
||||
onChange: (v) => {
|
||||
ctx.values[name] = v;
|
||||
},
|
||||
});
|
||||
control = pick.root;
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => {
|
||||
pick.setOptions(
|
||||
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
ctx.values[name] ?? "",
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-logic__input",
|
||||
attrs: { type: "text", inputmode: "decimal" },
|
||||
});
|
||||
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
|
||||
box.value = ctx.values[name] ?? "";
|
||||
box.addEventListener("input", () => {
|
||||
ctx.values[name] = box.value;
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-logic__input",
|
||||
attrs: { type: "text", inputmode: "decimal" },
|
||||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||||
const wide = label.length > 10;
|
||||
return el("label", {
|
||||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
|
||||
box.value = ctx.values[name] ?? "";
|
||||
box.addEventListener("input", () => {
|
||||
ctx.values[name] = box.value;
|
||||
});
|
||||
control = box;
|
||||
}
|
||||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||||
const wide = label.length > 10;
|
||||
return el("label", {
|
||||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
});
|
||||
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
|
||||
const out = el("div", { className: "m01-logic__calc-out" });
|
||||
const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
|
||||
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
|
||||
@@ -131,7 +147,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
if (!fields.length && !autoAsked.has(ctx.values)) {
|
||||
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
|
||||
queueMicrotask(() => {
|
||||
if (previewSeq === mySeqAtOpen) host.querySelector("button")?.click();
|
||||
if (previewSeq === mySeqAtOpen) again();
|
||||
});
|
||||
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
|
||||
autoAsked.add(ctx.values);
|
||||
@@ -142,7 +158,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
|
||||
for (const n of names) ctx.values[n] = String(auto[n]);
|
||||
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
|
||||
host.querySelector("button")?.click();
|
||||
again();
|
||||
});
|
||||
}
|
||||
const run = async (): Promise<void> => {
|
||||
@@ -161,6 +177,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
const body = {
|
||||
key: ctx.savedKey ?? ctx.row.키,
|
||||
inputs,
|
||||
...pickBody(ctx.values),
|
||||
...(draft ? { row: ctx.row, file: ctx.file } : {}),
|
||||
};
|
||||
const answer = await runCalc(body);
|
||||
@@ -171,12 +188,14 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
const runButton = createButton({ label: tx("Calc_Run"), onClick: () => void run() });
|
||||
runButton.setAttribute("data-calc-run", "");
|
||||
host.replaceChildren(
|
||||
el("h3", { text: tx("Calc_Title") }),
|
||||
...(fields.length
|
||||
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||||
...(fields.length || materials.length
|
||||
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||||
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
|
||||
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
|
||||
runButton,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -201,7 +220,19 @@ function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLEle
|
||||
const rows = answer.lines.map((line) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
|
||||
...(line.단위경고
|
||||
? [
|
||||
el("span", {
|
||||
className: "m01-logic__tag m01lab__warn",
|
||||
text: `${tx("Calc_UnitWarn")} · ${line.단위경고}`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
|
||||
el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_CalcPick.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나
|
||||
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import type { ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { condText, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
|
||||
interface Chosen {
|
||||
ref: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 로직마다 넣은 값(`values`)에 딸린 고른 재료 — 다시 그려도 남고 서버에는 계산 요청에만 실림 */
|
||||
const chosen = new WeakMap<Record<string, string>, Map<number, Chosen>>();
|
||||
const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
|
||||
let mine = chosen.get(values);
|
||||
if (!mine) chosen.set(values, (mine = new Map()));
|
||||
return mine;
|
||||
};
|
||||
|
||||
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
|
||||
export function pickBody(values: Record<string, string>): { 고름?: Record<string, string> } {
|
||||
const mine = picksOf(values);
|
||||
return mine.size
|
||||
? { 고름: Object.fromEntries([...mine].map(([i, c]) => [String(i), c.ref])) }
|
||||
: {};
|
||||
}
|
||||
|
||||
const nameOf = (row: Row): string => `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
|
||||
const firstWord = (text: string): string => text.trim().split(/\s+/)[0] ?? "";
|
||||
|
||||
const MATERIAL_KEYS = /^MT\d{6}$/;
|
||||
const MACHINE_CODE = /^\d{4}-\d{4}$/;
|
||||
const MACHINE_KEY = /^EQ\d{6}$/;
|
||||
|
||||
/** 재료 키 목록 입력 — 표 모달로 바뀌어 걷음 */
|
||||
export const isMaterialKeyInput = (spec: LogicInput): boolean =>
|
||||
!!spec.고르기?.length && spec.고르기.every((o) => MATERIAL_KEYS.test(String(o)));
|
||||
|
||||
const isMachineInput = (spec: LogicInput): boolean =>
|
||||
!!spec.고르기?.length &&
|
||||
spec.고르기.every((o) => MACHINE_CODE.test(String(o)) || MACHINE_KEY.test(String(o)));
|
||||
|
||||
const pickButton = (label: string, onClick: () => void): HTMLButtonElement =>
|
||||
createButton({ label, variant: "ghost", onClick });
|
||||
|
||||
const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m01-logic__field m01-logic__field--wide",
|
||||
children: [el("span", { text: label }), el("div", { children: [current, button] })],
|
||||
});
|
||||
|
||||
/** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */
|
||||
export function materialRows(
|
||||
row: LogicRow,
|
||||
prices: Record<string, ElementBrief | null>,
|
||||
values: Record<string, string>,
|
||||
again: () => void,
|
||||
): HTMLElement[] {
|
||||
const mine = picksOf(values);
|
||||
return (row.호표 ?? [])
|
||||
.map((item, i) => ({ item, i }))
|
||||
.filter((x) => x.item.종류 === "재료")
|
||||
.map(({ item, i }) => {
|
||||
const cond =
|
||||
typeof item.요소 === "object" && item.요소 !== null
|
||||
? (item.요소 as unknown as PickCond)
|
||||
: null;
|
||||
const brief = (cond ? prices[condText(cond)] : prices[item.요소]) ?? null;
|
||||
const label = item.이름 ?? (brief?.이름 as string | undefined) ?? "";
|
||||
const held = mine.get(i);
|
||||
const nowRef = held?.ref ?? brief?.ref;
|
||||
const nowName =
|
||||
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
|
||||
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
|
||||
.map((w) => firstWord(String(w ?? "")))
|
||||
.filter((w, k, all) => w && all.indexOf(w) === k);
|
||||
const current = el("span", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${nowName || tx("Mat_NoPrice")} `,
|
||||
});
|
||||
const button = pickButton(tx("Pick_Item"), () =>
|
||||
openPickTable({
|
||||
kind: "재료",
|
||||
title: `${tx("Pick_MatTitle")} · ${label}`,
|
||||
seed: seeds[0] ?? "",
|
||||
alt: seeds.slice(1),
|
||||
isCurrent: (r) => String(r["키"]) === nowRef,
|
||||
onPick: (r) => {
|
||||
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
|
||||
current.textContent = `${nameOf(r)} `;
|
||||
again();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button);
|
||||
});
|
||||
}
|
||||
|
||||
/** 기계 입력 칸 — 표 모달로 고르면 입력값(원문 분류번호 · 키 목록이면 키) */
|
||||
const machines = new Map<string, Row | null>();
|
||||
async function machineRow(code: string): Promise<Row | null> {
|
||||
if (!machines.has(code)) {
|
||||
let found: Row | null = null;
|
||||
try {
|
||||
const got = await fetchRows("기계.json", 1, 5, code);
|
||||
found = got.rows.find((r) => r["원문번호"] === code || r["키"] === code) ?? null;
|
||||
} catch {
|
||||
found = null;
|
||||
}
|
||||
machines.set(code, found);
|
||||
}
|
||||
return machines.get(code) ?? null;
|
||||
}
|
||||
|
||||
export function machineControl(
|
||||
spec: LogicInput,
|
||||
values: Record<string, string>,
|
||||
again: () => void,
|
||||
): HTMLElement | null {
|
||||
if (!isMachineInput(spec)) return null;
|
||||
const current = el("span", { className: "m01-logic__muted" });
|
||||
const paint = (): void => {
|
||||
const code = values[spec.이름] ?? "";
|
||||
const known = machines.get(code);
|
||||
current.textContent = `${code ? `${code}${known ? ` · ${nameOf(known)}` : ""}` : tx("Mat_NoPrice")} `;
|
||||
if (code && !machines.has(code)) void machineRow(code).then(paint);
|
||||
};
|
||||
paint();
|
||||
const button = pickButton(tx("Pick_Machine"), () => {
|
||||
const code = values[spec.이름] ?? "";
|
||||
const known = machines.get(code);
|
||||
openPickTable({
|
||||
kind: "기계",
|
||||
title: `${tx("Pick_Machine")} · ${spec.이름}`,
|
||||
seed: String(known?.["이름"] ?? spec.이름),
|
||||
isCurrent: (r) => !!code && (r["원문번호"] === code || r["키"] === code),
|
||||
onPick: (r) => {
|
||||
const useKey = MACHINE_KEY.test(String(spec.고르기?.[0]));
|
||||
const value = String(useKey ? r["키"] : r["원문번호"]);
|
||||
machines.set(value, r);
|
||||
values[spec.이름] = value;
|
||||
paint();
|
||||
again();
|
||||
},
|
||||
});
|
||||
});
|
||||
return el("div", { children: [current, button] });
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Logic_PickTable.ts
|
||||
* 시험 계산의 재료 · 기계 고르기 모달 — 마스터 컨테이너와 같은 표(`GET /rows`) · 쪽 나눔 · 칸 글
|
||||
* 검색 칸을 채워 열고 · 잡힌 품목 칠함 · 한 줄 누르면 고름 · 저장 안 함(읽기만)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
|
||||
import { buildPager, flatten, show } from "./M01_MasterData_UI_Cells";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
export type PickTableKind = "재료" | "기계";
|
||||
|
||||
const FILES: Record<PickTableKind, string> = { 재료: "재료_자재품목.json", 기계: "기계.json" };
|
||||
/** 표에 보일 칸 — 컨테이너 표의 칸 이름(묶음 값은 「값›가격」) */
|
||||
const COLS: Record<PickTableKind, string[]> = {
|
||||
재료: [
|
||||
"키",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
],
|
||||
기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"],
|
||||
};
|
||||
const SIZE = 30;
|
||||
|
||||
export interface PickTableOptions {
|
||||
kind: PickTableKind;
|
||||
title: string;
|
||||
/** 검색 칸을 채워 엶 — 결과가 없으면 첫 낱말만으로 다시 · 그래도 없으면 `alt` 를 차례로 */
|
||||
seed: string;
|
||||
alt?: string[];
|
||||
/** 지금 잡힌 줄이면 true — 그 줄을 칠함 */
|
||||
isCurrent: (row: Row) => boolean;
|
||||
onPick: (row: Row) => void;
|
||||
}
|
||||
|
||||
export function openPickTable(opt: PickTableOptions): void {
|
||||
const close = (): void => back.remove();
|
||||
const searchField = createInputField({
|
||||
type: "search",
|
||||
placeholder: tx("Pick_Search"),
|
||||
value: opt.seed,
|
||||
});
|
||||
const search = searchField.input;
|
||||
const host = el("div", { className: "m01-logic__pick-list" });
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01-logic__pick--table",
|
||||
attrs: { role: "dialog", "aria-label": opt.title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h3", { text: opt.title }),
|
||||
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
searchField.root,
|
||||
host,
|
||||
],
|
||||
});
|
||||
const back = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
back.addEventListener("click", (ev) => ev.target === back && close());
|
||||
back.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
|
||||
let page = 1;
|
||||
const tried = new Set([opt.seed]);
|
||||
let seq = 0;
|
||||
const load = async (fallback = false): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const q = search.value.trim();
|
||||
const got = await fetchRows(FILES[opt.kind], page, SIZE, q);
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
if (!got.total && fallback) {
|
||||
const next = [...(q.includes(" ") ? [q.split(/\s+/)[0]] : []), ...(opt.alt ?? [])].find(
|
||||
(w) => w && !tried.has(w),
|
||||
);
|
||||
if (next) {
|
||||
tried.add(next);
|
||||
search.value = next;
|
||||
return load(true);
|
||||
}
|
||||
}
|
||||
const cols = COLS[opt.kind];
|
||||
const body = got.rows.map((row) => {
|
||||
const flat = flatten(row);
|
||||
const tr = el("tr", {
|
||||
className: `m01lab__pickrow${opt.isCurrent(row) ? " is-picked" : ""}`,
|
||||
children: cols.map((c) =>
|
||||
el("td", { text: show(flat[c]), attrs: { title: show(flat[c]) } }),
|
||||
),
|
||||
});
|
||||
tr.addEventListener("click", () => {
|
||||
close();
|
||||
opt.onPick(row);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
host.replaceChildren(
|
||||
buildPager(got.total, SIZE, got.page, (next) => {
|
||||
page = next;
|
||||
void load();
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-master__grid-wrap",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-master__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
|
||||
}),
|
||||
el("tbody", { children: body }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
host.querySelector(".is-picked")?.scrollIntoView({ block: "nearest" });
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
|
||||
}
|
||||
};
|
||||
let timer = 0;
|
||||
search.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
page = 1;
|
||||
void load();
|
||||
}, 300);
|
||||
});
|
||||
document.body.append(back);
|
||||
search.focus();
|
||||
void load(true);
|
||||
}
|
||||
@@ -67,6 +67,11 @@ const TEXT = {
|
||||
Pick_Title: ["요소 찾기", "Find element"],
|
||||
Pick_Search: ["이름·키·원문번호", "Name · key · source no."],
|
||||
Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"],
|
||||
Pick_Item: ["품목 고르기", "Pick item"],
|
||||
Pick_Machine: ["기계 고르기", "Pick machine"],
|
||||
Pick_MatTitle: ["자재품목 고르기", "Pick a material item"],
|
||||
Pick_Chosen: ["고름", "Picked"],
|
||||
Calc_UnitWarn: ["단위 다름", "Unit differs"],
|
||||
Pick_Close: ["닫기", "Close"],
|
||||
Pick_All: ["(전체)", "(All)"],
|
||||
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
|
||||
|
||||
Reference in New Issue
Block a user