/* ============================================================================= * M02_MasterTemplete_Find_Pick.ts * M02 찾아 넣기 창(PLAN 721-2 · 요구서_내역16 1장 6 · 7 · 2장 1 · 2) — 탭 [M01 전체 | M02 단가산출 | 일위대가] · * 찾기 칸 하나 · 표 하나(품명 · 규격 · 단위 · 금액 · 구분 · 출처) · 옛 거름 값(종류 · 공종 · 구분 · 상세구분 · 층) = 「구분」 · 「출처」 열 * · M01 전체 = 단가산출 로직(기준 목록 `/logic-lib/standard` · 줄 누름 = 계산서 보기 → [담기]) + * 인력(값 있는 노임 직종) · 재료(자재품목) = `/logic-lib/pick` 서버 찾기(친 낱말 · 상한 200) · 기계 뺌 · 실무 호표 뺌 * · M02 단가산출 · 일위대가 = 위 층(마스터 › 회사 › 개인) 서고 목록(`/layers/{층}/templates`) · 고름 = 참조 {층 · 주인 · 키} * · 인력 · 재료 고름 = `element` · 일위대가 고정 줄 꼴 = `fixedOf`(705-1 이음 노임 LB · 자재 MT · 수량 1 은 부르는 쪽) · * `element` 없으면 그 줄 회색 막힘(까닭 = 일위대가 표 개편 뒤 · 723) * · 부르는 곳 = M02 단가산출 [기본 로직 선택](`logicOnly`) · [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가] * (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`) * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { aliasNote, matchLogic } from "@ui/ui_template_logic_find"; import { openModal } from "@ui/ui_template_modal"; import { createTabs } from "@ui/ui_template_tabs"; import { createText } from "@ui/ui_template_text"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import type { LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { fetchLibLogics, fetchStandard, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { openStandardSheet } from "./M02_MasterTemplete_Labor_Sheet"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { ownOwner } from "./M02_MasterTemplete_Layer"; import { listLayerDocs, SANGEUN_KIND, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; type Scope = Omit; type Tab = "m01" | "sk" | "pre"; /** M01 인력 · 재료 한 줄(`/logic-lib/pick` · 코드 = LB- · MT- 키) */ export interface M01Element { 종류: "인력" | "재료"; 코드: string; 이름: string; 규격: string; 단위: string; 값: number | null; } export interface FindPickOptions { layer: Layer; owner: string | null; title?: string; /** M01 단가산출 로직 고름 */ logic: (x: LogicSummary) => void; /** M02 단가산출 · 일위대가 고름(참조) */ ref?: (ref: LaborRef, name: string) => void; /** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */ element?: (e: M01Element) => void; /** 로직 줄만(탭 · 인력 · 재료 없음 — [기본 로직 선택]) */ logicOnly?: boolean; } /** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */ export function fixedOf(e: M01Element): LaborFixed { const labor = e.종류 === "인력"; return { 이름: e.이름, 규격: e.규격, 단위: e.단위, 단가: { 노무비: labor ? (e.값 ?? 0) : 0, 재료비: labor ? 0 : (e.값 ?? 0), 경비: 0 }, 이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }, }; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ export async function upper(layer: Layer, owner: string | null): Promise { if (layer === "project") return []; const own = layer === "system" ? null : (owner ?? ownOwner(layer, await fetchSessionUser().catch(() => null))); const out: Scope[] = [{ 층: "system", 주인: null }]; if (own) out.push({ 층: "company", 주인: own.split("/")[0] }); if (own && layer === "personal") out.push({ 층: "personal", 주인: own }); return out; } const LAYER_TEXT: Record = { system: "Lib_System", company: "Lib_Company", personal: "Lib_Personal", }; /** 노임 「원/일」 → 줄 단위 「인」 · 「원/시」 → 「시」 · 재료 단위 그대로 */ const unitOf = (u: string): string => (u === "원/일" ? "인" : u.replace(/^원\//, "")); const join = (...xs: Array): string => xs.filter(Boolean).join(" · "); interface PickItem { ref: string; 구분?: string | null; 이름: string; 규격?: string | null; 단위?: string | null; 값: unknown; } /** 표 한 줄 + 누름 */ interface Line { 품명: string; 규격: string; 단위: string; 금액: number | null; 구분: string; 출처: string; dim?: boolean; go: () => void; } export function openFindPicker(opt: FindPickOptions): void { let tab: Tab = "m01"; let close = (): void => {}; let logics: LogicSummary[] = []; let std: StandardRow[] | null = null; let people: PickItem[] = []; let goods: PickItem[] = []; const lists: Record<"sk" | "pre", SangeunRow[] | null> = { sk: null, pre: null }; let shown: Line[] = []; let seq = 0; let timer = 0; const search = createInputField({ type: "search", placeholder: L("M02_FindSearch") }); const hint = createText(""); const table = createDataTable({ caption: L("M02_FindTitle"), columns: [ { key: "품명", label: tc("Std_Name"), width: 200 }, { key: "규격", label: tc("Std_Spec"), width: 110 }, { key: "단위", label: tc("Std_Unit"), width: 44 }, { key: "금액", label: tc("Std_Money"), kind: "number", width: 84 }, { key: "구분", label: L("M02_FindClass"), width: 180 }, { key: "출처", label: tc("Std_Source"), width: 120 }, ], rows: [], rowClass: (i) => (shown[i]?.dim ? "is-dim" : null), onRowClick: (i) => shown[i]?.go(), }); const take = (f: () => void) => (): void => { close(); f(); }; const logicLines = (q: string): Line[] => (std ?? logics.map(plain)).flatMap((x) => { const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1 if (!hit.ok) return []; const logic = logics.find((y) => y.키 === x.키); return [ { 품명: join(x.이름, aliasNote(hit.alias)), 규격: x.규격, 단위: `${x.단위}${x.막힘 ? ` · ${tc("Pick_Blocked")}` : ""}`, 금액: x.금액, 구분: join(L("M02_FindLogic"), x.공종, x.구분, x.상세구분), 출처: x.출처, go: () => logic && openStandardSheet( x, take(() => opt.logic(logic)), ), }, ]; }); const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] => items.map((m) => { const e: M01Element = { 종류: kind, 코드: m.ref, 이름: m.이름, 규격: m.규격 ?? "", 단위: unitOf(m.단위 ?? ""), 값: Number.isFinite(Number(m.값)) && m.값 !== null ? Number(m.값) : null, }; const put = opt.element; return { 품명: m.이름, 규격: e.규격, 단위: e.단위, 금액: e.값, 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), 출처: `M01 ${m.ref}`, dim: !put, go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"), }; }); const docLines = (rows: SangeunRow[], kind: string): Line[] => rows.map((x) => ({ 품명: x.제목 || x.이름, 규격: "", 단위: x.단위 ?? "", 금액: x.기준금액, 구분: join(kind, x.구분, x.상세구분), 출처: LAYER_TEXT[x.ref.층] ? tc(LAYER_TEXT[x.ref.층]) : x.ref.층, go: take(() => opt.ref?.(x.ref, x.제목 || x.이름)), })); function draw(): void { const q = search.input.value.trim(); if (tab === "m01") { shown = logicLines(q); if (!opt.logicOnly) shown.push(...elementLines("인력", people), ...elementLines("재료", goods)); } else { const words = q.toLowerCase().split(/\s+/).filter(Boolean); const kind = tab === "sk" ? L("M02_PickSangeun") : L("M02_PrecomboTitle"); shown = docLines(lists[tab] ?? [], kind).filter((x) => { const text = join(x.품명, x.구분).toLowerCase(); return words.every((w) => text.includes(w)); }); } const loading = tab === "m01" ? !logics.length : !lists[tab]; hint.textContent = loading ? tc("Loading") : shown.length ? `${shown.length}` : tc("Pick_None"); table.setRows(shown.map(({ go: _go, dim: _dim, ...row }) => row)); } /** 인력 · 재료 = 서버 찾기(친 낱말) — 재료는 낱말 있을 때만 · 늦게 온 답은 버림 */ async function findElements(): Promise { if (opt.logicOnly) return; const mine = ++seq; const q = search.input.value.trim(); const get = (kind: "job" | "price"): Promise => call<{ items: PickItem[] }>( `/logic-lib/pick?kind=${kind}&q=${encodeURIComponent(q)}&limit=200`, ).then((d) => d.items); try { const [p, g] = await Promise.all([get("job"), q ? get("price") : Promise.resolve([])]); if (mine !== seq) return; [people, goods] = [p, g]; } catch (error) { if (mine !== seq) return; showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } if (tab === "m01") draw(); } search.input.addEventListener("input", () => { draw(); window.clearTimeout(timer); timer = window.setTimeout(() => void findElements(), 250); }); const tabs = opt.logicOnly ? null : createTabs({ tabs: [ { key: "m01", label: L("M02_FindM01") }, { key: "sk", label: L("M02_PickSangeun") }, { key: "pre", label: L("M02_PrecomboTitle") }, ], value: tab, kind: "line", onChange: (key) => { tab = key as Tab; draw(); }, }); ({ close } = openModal({ title: opt.title ?? L("M02_FindTitle"), closeLabel: tc("Pick_Close"), dialogClass: "m01-logic__pick m01-combo", width: "960px", // 표 여섯 칸 backdropClass: "m01-logic__backdrop", mount: (body) => body.append( el("div", { className: "m02-std-pick", attrs: { "data-find-pick": "" }, children: [...(tabs ? [tabs.root] : []), search.root, hint, table.root], }), ), })); search.input.focus(); draw(); const fail = (error: unknown): void => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)]) .then(([l, s]) => { logics = l; std = s?.줄.length ? s.줄 : null; // 빈 줄 = 서버가 아직 셈 — 로직 줄 그대로 draw(); }) .catch(fail); void findElements(); if (tabs) void upper(opt.layer, opt.owner) .then((scopes) => Promise.all( (["sk", "pre"] as const).map((t) => listLayerDocs(scopes, t === "sk" ? SANGEUN_KIND : "precombo").then((rows) => { lists[t] = rows; tabs.setBadge(t, rows.length); }), ), ), ) .catch((error) => { lists.sk ??= []; lists.pre ??= []; fail(error); }) .finally(draw); } /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ const plain = (x: LogicSummary): StandardRow => ({ 키: x.키, 이름: x.이름, 규격: "", 단위: x.결과단위.replace(/^원\/?/, ""), 금액: null, 출처: x.출처 ?? "", 공종: "", 별칭: x.별칭 ?? "", 구분: x.구분, 상세구분: x.상세구분, 막힘: x.blocked, 바꿈: {}, });