Files
Aislo/M02_MasterTemplete/M02_Material_Pick.ts
T

238 lines
8.1 KiB
TypeScript

/* =============================================================================
* M02_Material_Pick.ts
* M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀).
* · 일위대가 [자재 추가] 단추(`addMaterialButton` · 148 에서 `Labor_Detail` 이 덜어 냄)
* · 개인 층(148-3) = 개인 자재(「개인」 표 · 앞) + M01 · 머리 [신규 자재] → 품명 · 단위(필수) · 규격 · 단가 →
* 서버가 품번(`PM` + 6자리)을 주고 그 줄로 고름(`M02_Material_Own`)
* 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`.
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import { call } from "./M02_MasterTemplete_Api_Fetch";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
/** 고른 자재 한 줄 — M01 자재(키 `MT`) · 개인 자재(키 `PM`) */
export interface MaterialItem {
코드: string;
이름: string | null;
규격: string | null;
단위: string | null;
}
/** 개인 자재 한 줄(서버 `개인자재.json` 꼴) */
interface OwnRow {
키: string;
이름: string;
규격: string;
단위: string;
값: number | null;
}
/** 개인 층 주인(148-3 · 지금 층이면 null) — 넘기면 개인 자재 · [신규 자재] */
export interface OwnLayer {
owner: string | null;
}
const TEXT = {
pickTitle: "자재 추가",
pickClose: "닫기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
noMatch: "맞는 자재 없음",
searchFailed: "자재 목록을 못 받음",
newMat: "신규 자재",
own: "개인",
noPrice: "단가 없음",
name: "품명",
spec: "규격",
unit: "단위",
price: "단가(원 · 비우면 단가 없음)",
key: "품번 — 저장 때 자동",
save: "저장",
back: "목록으로",
needName: "품명 · 단위는 꼭 적음",
};
const ownPath = (own: OwnLayer): string =>
`/layers/personal/materials${own.owner ? `?owner=${encodeURIComponent(own.owner)}` : ""}`;
const toItem = (r: OwnRow): MaterialItem => ({
코드: r.키,
이름: r.이름,
규격: r.규격,
단위: r.단위,
});
/** 찾기 낱말이 모두 이름 · 규격 · 키에 듦(M01 찾기와 같은 규칙) */
const matches = (r: OwnRow, words: string[]): boolean => {
const hay = `${r.이름} ${r.규격} ${r.키}`.toLowerCase();
return words.every((w) => hay.includes(w));
};
/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 · `own` = 개인 층(개인 자재 · [신규 자재]) */
export function pickMaterial(done: (item: MaterialItem) => void, own?: OwnLayer): void {
let mine: OwnRow[] = [];
const loaded = own
? call<{ 줄: OwnRow[] }>(ownPath(own))
.then((d) => void (mine = d.줄))
.catch(() => undefined)
: Promise.resolve();
let showNew = (): void => undefined;
const newButton = createButton({
label: TEXT.newMat,
variant: "ghost",
onClick: () => showNew(),
});
newButton.dataset.materialNew = "";
openModal({
title: TEXT.pickTitle,
closeLabel: TEXT.pickClose,
dialogClass: "m02-modal m02-pick",
...(own ? { actions: [newButton] } : {}),
mount: (body, close) => {
const search = createInputField({ placeholder: TEXT.search, type: "search" });
const list = el("div", { className: "m02-pick__list" });
const item = (m: MaterialItem, tags: string[] = []): HTMLElement => {
const row = el("button", {
className: "m02-pick__item",
attrs: { type: "button", "data-material": m.코드 },
children: [
el("span", { className: "m02-pick__key", text: m.코드 }),
`${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "),
...tags.map((t) => el("span", { className: "m02-pick__tag", text: t })),
],
});
row.addEventListener("click", () => {
done(m);
close();
});
return row;
};
let seq = 0;
const paint = async (): Promise<void> => {
const ticket = ++seq;
list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching }));
try {
const q = search.input.value.trim();
const [hits] = await Promise.all([searchMaterials(q), loaded]);
if (ticket !== seq) return;
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
const ownHits = mine
.filter((r) => matches(r, words))
.map((r) => item(toItem(r), r.값 === null ? [TEXT.own, TEXT.noPrice] : [TEXT.own]));
const all = [...ownHits, ...hits.map((m) => item(m))];
list.replaceChildren(
...(all.length
? all
: [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]),
);
} catch {
if (ticket === seq) showToast(TEXT.searchFailed, "error");
}
};
let timer = 0;
search.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void paint(), 250);
});
const picking = [search.root, list];
if (own)
showNew = () => {
newButton.hidden = true;
body.replaceChildren(
newForm(
own,
search.input.value.trim(),
(row) => {
done(toItem(row));
close();
},
() => {
newButton.hidden = false;
body.replaceChildren(...picking);
},
),
);
};
body.append(...picking);
void paint();
search.input.focus();
},
});
}
/** 신규 자재 입력(148-3) — [저장] = 서버 추가 → 그 줄로 `saved` · 실패는 서버 까닭 그대로 */
function newForm(
own: OwnLayer,
seed: string,
saved: (row: OwnRow) => void,
back: () => void,
): HTMLElement {
const name = createInputField({ label: TEXT.name, value: seed, required: true });
const spec = createInputField({ label: TEXT.spec });
const price = createInputField({ label: TEXT.price, type: "number", min: 0 });
let unit = "";
const unitHost = el("span");
const drawUnit = (): void =>
unitHost.replaceChildren(
createUnitPick({
value: unit || NO_UNIT,
ariaLabel: TEXT.unit,
onPick: (u) => {
unit = u === NO_UNIT ? "" : u;
drawUnit();
},
}),
);
drawUnit();
const save = createButton({
label: TEXT.save,
variant: "filled",
onClick: async () => {
const 이름 = name.input.value.trim();
if (!이름 || !unit) return showToast(TEXT.needName, "error");
const raw = price.input.value.trim();
try {
saved(
await call<OwnRow>(ownPath(own), {
method: "POST",
body: JSON.stringify({
이름,
규격: spec.input.value.trim(),
단위: unit,
값: raw === "" ? null : Number(raw),
}),
}),
);
} catch (e) {
showToast(e instanceof Error ? e.message : String(e), "error");
}
},
});
return el("div", {
className: "m02-pick__new",
children: [
name.root,
spec.root,
el("div", {
className: "ui-field",
children: [el("label", { className: "ui-field__label", text: TEXT.unit }), unitHost],
}),
price.root,
el("p", { className: "m02-struct__empty", text: TEXT.key }),
el("div", {
className: "ui-btn-group",
children: [createButton({ label: TEXT.back, variant: "ghost", onClick: back }), save],
}),
],
});
}
/** 일위대가 [자재 추가] 단추 — 누르면 고르기 창 · 고른 줄로 `done`(막을지는 부르는 쪽) */
export const addMaterialButton = (
label: string,
done: (item: MaterialItem) => void,
own?: OwnLayer,
): HTMLElement =>
createButton({ label, variant: "filled", onClick: () => pickMaterial(done, own) });