Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_UnitPick.ts
|
|
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음
|
|
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
|
|
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 서버 /standards) — 다른 판도 이것을 씀
|
|
* 글 [한, 영] 짝은 아래 TEXT
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { cfStandards } from "./M02_DF_Api";
|
|
import "./M02_DF_UnitPick.css";
|
|
|
|
const TEXT = {
|
|
None: ["없음", "none"],
|
|
Filter: ["단위를 쳐서 찾음", "Type to filter units"],
|
|
Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"],
|
|
Pick: ["단위 고르기", "Pick a unit"],
|
|
} as const;
|
|
|
|
const tu = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
|
|
|
/** 「없음」 을 뜻하는 단위 글 */
|
|
export const NO_UNIT = "-";
|
|
|
|
/** 표준 표에서 읽은 것 — 단위 목록 · 단위마다 소수자리 제안(단위수량 쪽에서 가장 많이 쓴 자리) */
|
|
export interface UnitStandard {
|
|
units: string[];
|
|
places: Record<string, number>;
|
|
}
|
|
|
|
type UnitSource = () => Promise<UnitStandard>;
|
|
|
|
interface StandardRow {
|
|
"규격 단위"?: string | null;
|
|
"단위수량 단위"?: string | null;
|
|
"단위수량 소수자리"?: number | null;
|
|
}
|
|
|
|
/** 서버 표준 표(/standards) — 고친 표가 있으면 그것, 없으면 처음 값 */
|
|
const fromServer: UnitSource = async () => {
|
|
const table = (await cfStandards()).unit as { 줄: StandardRow[] };
|
|
const units = table.줄
|
|
.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]])
|
|
.filter((u): u is string => !!u);
|
|
const count: Record<string, Record<number, number>> = {};
|
|
for (const r of table.줄) {
|
|
const u = r["단위수량 단위"];
|
|
const p = r["단위수량 소수자리"];
|
|
if (!u || u === "없음" || typeof p !== "number") continue;
|
|
count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 };
|
|
}
|
|
const places = Object.fromEntries(
|
|
Object.entries(count).map(([u, c]) => [
|
|
u,
|
|
Number(Object.entries(c).sort((x, y) => y[1] - x[1])[0][0]),
|
|
]),
|
|
);
|
|
return { units, places };
|
|
};
|
|
|
|
let source: UnitSource = fromServer;
|
|
let cache: Promise<UnitStandard> | null = null;
|
|
|
|
/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */
|
|
export function setUnitSource(next: UnitSource): void {
|
|
source = next;
|
|
cache = null;
|
|
}
|
|
|
|
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 · 서버를 못 읽으면 원만(다음에 다시 읽음) */
|
|
export async function loadStandard(): Promise<UnitStandard> {
|
|
cache ??= source().then((std) => {
|
|
const set = new Set([...std.units, "원"]);
|
|
set.delete("없음");
|
|
set.delete(NO_UNIT);
|
|
return { units: [...set], places: std.places };
|
|
});
|
|
try {
|
|
return await cache;
|
|
} catch {
|
|
cache = null;
|
|
return { units: ["원"], places: {} };
|
|
}
|
|
}
|
|
|
|
/** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */
|
|
export async function suggestPlaces(unit: string): Promise<number | null> {
|
|
return (await loadStandard()).places[unit] ?? null;
|
|
}
|
|
|
|
export interface UnitPickOptions {
|
|
/** 지금 단위 — 「-」 = 없음 */
|
|
value: string;
|
|
onPick(unit: string): void;
|
|
/** 더 보일 단위(마스터 결과단위 등) */
|
|
extra?: string[];
|
|
readOnly?: boolean;
|
|
ariaLabel?: string;
|
|
}
|
|
|
|
/** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */
|
|
export function createUnitPick(opts: UnitPickOptions): HTMLElement {
|
|
const label = (u: string): string => (u === NO_UNIT ? `${NO_UNIT} (${tu("None")})` : u);
|
|
const root = el("span", { className: "m02-df-unitpick" });
|
|
const open = createButton({
|
|
label: opts.value || NO_UNIT,
|
|
variant: "ghost",
|
|
size: "sm",
|
|
disabled: opts.readOnly,
|
|
onClick: () => toggle(),
|
|
});
|
|
open.setAttribute("aria-label", `${opts.ariaLabel ?? tu("Pick")}: ${opts.value || NO_UNIT}`);
|
|
open.setAttribute("aria-haspopup", "listbox");
|
|
root.append(open);
|
|
if (opts.readOnly) return root;
|
|
|
|
const input = el("input", {
|
|
className: "ui-input ui-input--sm m02-df-unitpick__filter",
|
|
attrs: { type: "text", placeholder: tu("Filter"), "aria-label": tu("Filter") },
|
|
});
|
|
const list = el("ul", { className: "m02-df-unitpick__list", attrs: { role: "listbox" } });
|
|
const panel = el("div", { className: "m02-df-unitpick__panel", children: [input, list] });
|
|
panel.hidden = true;
|
|
root.append(panel);
|
|
|
|
let all: string[] = [];
|
|
let shown: string[] = [];
|
|
let at = 0;
|
|
const pick = (u: string): void => {
|
|
close();
|
|
if (u !== (opts.value || NO_UNIT)) opts.onPick(u);
|
|
};
|
|
const draw = (): void => {
|
|
const q = input.value.trim().toLowerCase();
|
|
shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q));
|
|
if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT);
|
|
at = Math.min(at, Math.max(0, shown.length - 1));
|
|
list.replaceChildren(
|
|
...(shown.length
|
|
? shown.map((u, i) => {
|
|
const b = el("button", {
|
|
className: `m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`,
|
|
text: label(u),
|
|
attrs: { type: "button", role: "option" },
|
|
});
|
|
b.addEventListener("click", () => pick(u));
|
|
return el("li", { children: [b] });
|
|
})
|
|
: [el("li", { className: "m02-df-unitpick__empty", text: tu("Empty") })]),
|
|
);
|
|
};
|
|
const close = (): void => {
|
|
panel.hidden = true;
|
|
document.removeEventListener("pointerdown", outside, true);
|
|
};
|
|
const outside = (e: Event): void => {
|
|
if (!root.contains(e.target as Node)) close();
|
|
};
|
|
function toggle(): void {
|
|
if (!panel.hidden) return close();
|
|
panel.hidden = false;
|
|
document.addEventListener("pointerdown", outside, true);
|
|
input.value = "";
|
|
void loadStandard().then(({ units }) => {
|
|
const mine = [...(opts.extra ?? []), ...units];
|
|
all = [NO_UNIT, ...new Set(mine.filter((u) => u && u !== NO_UNIT))];
|
|
at = 0;
|
|
draw();
|
|
input.focus();
|
|
});
|
|
}
|
|
input.addEventListener("input", () => ((at = 0), draw()));
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") return (e.stopPropagation(), close(), open.focus());
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
at = Math.max(0, Math.min(shown.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)));
|
|
return draw();
|
|
}
|
|
if (e.key === "Enter" && shown[at] !== undefined) {
|
|
e.preventDefault();
|
|
pick(shown[at]);
|
|
}
|
|
});
|
|
return root;
|
|
}
|