Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
238 lines
8.1 KiB
TypeScript
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) });
|