/* ============================================================================= * B09_Estimation_UI_Store.ts * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). * * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) * 연결 = 단가연결 모달과 같은 길(`…_Bill_Link` · 702-3 즉시 금액) — 호표 = 연결 · 조합 · 사전조합 = 사본 들여옴 · * 품셈 = 넣을 값 창(대표 규격 채움) · 지난 공사 = 서버가 이 공사 서고 사본으로(`…_Past` · 덮개째 · * 단가 = 이 공사 단가 책) → 받은 덮개를 그 cover 가 비었을 때만 화면 캐시에 얹음 * 틀 = 공용 오른쪽 서랍(`ui_template_side_panels` · 접고 펴기 · 폭 끌기 · 좁은 화면 = 아래로) * 창고 하나 = 페이지 한 벌(다시 그려도 찾기 글 · 탭 · 받은 목록 남음) · 이 공사 = 그릴 때마다 새로 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import type { DataRow } from "@ui/ui_template_data_table"; import { el, showToast } from "@ui/ui_template_elements"; import { createFindList, type FindRow } from "@ui/ui_template_find_list"; import { loadRows } from "@ui/ui_template_item_picker"; import { itemName } from "@ui/ui_template_item_picker_data"; import { matchLogic } from "@ui/ui_template_logic_find"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createTabs } from "@ui/ui_template_tabs"; import { createText } from "@ui/ui_template_text"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchLibrary, fetchStandard, fetchStandardSheet, type StandardRow, } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; type Source = "here" | "mine" | "company" | "poomsem" | "past"; const SOURCES: Array<[Source, string]> = [ ["here", "이 공사"], ["mine", "내 것"], ["company", "회사"], ["poomsem", "품셈"], ["past", "지난 공사"], ]; /** 한 번에 그리는 줄 — 품셈 1만 줄 · 찾기로 좁힘 */ const SHOWN = 200; const DRAG = "text/x-b09-store"; interface Item { tag: string; name: string; sub: string; unit: string; hit: (q: string) => boolean; go: (ctx: TabContext, apply: ApplyRef) => void; sheet?: (put: () => void) => void; } const words = (q: string): string[] => q.toLowerCase().split(/\s+/).filter(Boolean); const has = (text: string) => (q: string) => words(q).every((w) => text.toLowerCase().includes(w)); const onPayload = (ctx: TabContext) => (p: Row) => Object.assign(ctx.payload, p); function hereItems(ctx: TabContext): Item[] { const of = (tab: "dg" | "sg", list: Row[] | undefined): Item[] => (list || []).map((r) => { const tag = tab === "dg" ? `일위대가 ${r.no}호` : `단가산출 ${r.no}호`; const name = String(r.name || ""); return { tag, name, sub: String(r.spec || ""), unit: String(r.unit || ""), hit: has(`${tag} ${name} ${r.spec || ""}`), go: (_c, apply) => apply({ tab, key: r.key }, r), }; }); return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)]; } /** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */ async function layerItems(layer: "personal" | "company"): Promise { const owner = ownOwner(layer, await fetchSessionUser().catch(() => null)); const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer); const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : []; return [ ...combos.map((r) => { const name = itemName(r); const pick = { kind: "combo" as const, layer, owner: r.owner ?? null, key: r.key }; return { tag: "구조물 조합", name, sub: "", unit: String(r.단위 || ""), hit: has(`구조물 조합 ${name}`), go: (ctx: TabContext, apply: ApplyRef) => void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; }), ...pres.map((r) => { const name = r.제목 || r.이름; const pick = { kind: "precombo" as const, layer, owner, key: r.이름 }; return { tag: "일위대가", name, sub: [r.구분, r.상세구분].filter(Boolean).join(" "), unit: String(r.단위 || ""), hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), go: (ctx: TabContext, apply: ApplyRef) => void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; }), ]; } /** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ function askStandard(apply: ApplyRef, r: StandardRow): void { const open = (given: Row): void => askInputs(apply, r.키, r.이름, { given }); fetchStandardSheet(r).then( (sheet) => open((sheet as { 입력?: Row }).입력 ?? r.바꿈), () => open(r.바꿈), ); } /** 품셈 = 704-1 기준 목록(품명 · 규격 · 금액 · 실무 낱말 찾기) · 연결 = 넣을 값 창(대표 규격 채움) */ async function poomsemItems(): Promise { const list = await fetchStandard(); return list.줄.map((r) => ({ tag: r.공종 || "품셈", name: r.이름, sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`] .filter(Boolean) .join(" · "), unit: r.단위, hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok, go: (_c, apply) => askStandard(apply, r), sheet: (put) => openStandardSheet(r, put), })); } async function call(path: string, body?: Row): Promise { const projectId = currentProjectId(); if (!projectId) throw new Error("프로젝트 없음"); const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/${path}`, { method: body ? "POST" : "GET", credentials: "include", headers: { "Content-Type": "application/json" }, ...(body && { body: JSON.stringify(body) }), }); const got = (await response.json().catch(() => null)) as Row | null; if (!response.ok || !got) { const detail = got?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } return got; } /** 지난 공사 가져옴 — 이 공사 서고 사본 · 덮개 = 그 cover 가 비었을 때만 얹음 */ async function importPast(ctx: TabContext, apply: ApplyRef, r: Row): Promise { showToast("지난 공사에서 가져오는 중…", "info"); try { const got = await call("past", { project_id: r.project_id, cover: r.cover }); if (!got.key) throw new Error("사본은 들여왔으나 일위대가를 못 만듦"); onPayload(ctx)(got.payload); const from = ((got.payload.sheets?.daega as Row[]) || []).find((x) => x.key === got.key); const cells: Row = got.덮개 || {}; const n = Object.keys(cells).length; let note = ""; if (n && got.cover) { const all: Row = { ...(ctx.user.daega_cover || {}) }; if (Object.keys(all[got.cover] || {}).length) note = " · 이 공사에 고친 칸이 있어 그대로 둠"; else { all[got.cover] = cells; ctx.user = { ...ctx.user, daega_cover: all }; // 아래 연결이 같은 캐시에 씀 note = ` · 고친 칸 ${n} 함께 옮김`; } } apply({ tab: "dg", key: got.key, lib: got.폴더 }, from); showToast(`「${r.project}」 일위대가로 연결함${note} — [저장]해야 남음`, "success"); } catch (error) { showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error"); } } async function pastItems(): Promise { const got = await call("past"); return ((got.rows as Row[]) || []).map((r) => ({ tag: String(r.project || ""), name: String(r.name || ""), sub: [r.kind, r.covers ? `고친 칸 ${r.covers}` : ""].filter(Boolean).join(" · "), unit: String(r.unit || ""), hit: has(`${r.project} ${r.name} ${r.spec} ${r.kind}`), go: (ctx, apply) => void importPast(ctx, apply, r), })); } const LOAD: Record, () => Promise> = { mine: () => layerItems("personal"), company: () => layerItems("company"), poomsem: poomsemItems, past: pastItems, }; /** 페이지 한 벌 창고 */ let store: { root: HTMLElement; bind: (ctx: TabContext, raw: DataRow[], table: HTMLElement) => void; } | null = null; function makeStore(): NonNullable { let ctx: TabContext | null = null; let raw: DataRow[] = []; let source: Source = "here"; const got: Partial> = {}; let shown: Item[] = []; const finder = createFindList({ placeholder: "품명 · 규격 · 실무 낱말로 찾기" }); const tabs = createTabs({ tabs: SOURCES.map(([key, label]) => ({ key, label })), value: source, kind: "pill", onChange: (key) => { source = key as Source; draw(); }, }); const guide = createText( "두 번 누름 = 고른 내역 줄에 단가연결 · 끌어서 내역 줄에 놓기 = 그 줄에 연결 · 우클릭 = 계산서 보기(품셈)", ); /** 그 줄에 연결 — 줄 = 고른 내역 줄(index 없음) · 연결 못 하는 줄이면 알림 */ const linkTo = (item: Item, at?: number): void => { if (!ctx) return; const key = pickedKey(); const i = at ?? raw.findIndex((r) => String(r.key) === key); const apply = i >= 0 ? applyAt(ctx, raw, i) : null; if (!apply) return void showToast("내역 항목 줄을 먼저 누름(자재 · 묶음 줄은 연결 없음)", "info"); item.go(ctx, apply); }; const draw = (): void => { const now = source === "here" ? (ctx ? hereItems(ctx) : []) : got[source]; if (source !== "here" && now === undefined) void load(source); if (!Array.isArray(now)) { finder.hint.textContent = now instanceof Error ? `못 읽음 — ${now.message}` : "읽는 중…"; return finder.setRows([]); } const q = finder.search.value.trim(); const found = now.filter((x) => x.hit(q)); shown = found.slice(0, SHOWN); const more = found.length > SHOWN ? ` · 앞 ${SHOWN} 줄만 — 찾기로 좁힘` : ""; finder.hint.textContent = found.length ? `${found.length} / ${now.length}${more}` : now.length ? "맞는 것 없음" : source === "past" ? "가져올 지난 공사 일위대가 없음(같은 회사 공사만)" : "비었음"; finder.setRows( shown.map((x, i) => ({ key: String(i), tag: x.tag, name: x.name, sub: x.sub, unit: x.unit, onPick: () => linkTo(x), })), ); for (const row of finder.root.querySelectorAll("[data-pick]")) { row.draggable = true; row.addEventListener("dragstart", (ev) => ev.dataTransfer?.setData(DRAG, row.dataset.pick!)); } for (const [key] of SOURCES) { const list = key === "here" ? (ctx ? hereItems(ctx) : []) : got[key]; tabs.setBadge(key, Array.isArray(list) ? list.length : 0); } }; const load = async (key: Exclude): Promise => { got[key] = "loading"; try { got[key] = await LOAD[key](); } catch (error) { got[key] = error instanceof Error ? error : new Error(String(error)); } draw(); }; finder.search.addEventListener("input", draw); // 한 번 누름 = 고름만(두 번째 누름 · 키보드 = 연결) finder.root.addEventListener( "click", (ev) => { if ((ev.target as Element).closest("[data-pick]") && (ev as MouseEvent).detail === 1) ev.stopPropagation(); }, true, ); finder.root.addEventListener("contextmenu", (ev) => { const row = (ev.target as Element).closest("[data-pick]"); const item = row ? shown[Number(row.dataset.pick)] : undefined; if (!item) return; ev.preventDefault(); openMenuAt(ev.clientX, ev.clientY, [ ["고른 내역 줄에 연결", () => linkTo(item)], ...(item.sheet ? [["계산서 보기", () => item.sheet?.(() => linkTo(item))] as [string, () => void]] : []), ]); }); const body = el("div", { className: "b09-store", attrs: { "data-b09-store": "" }, children: [tabs.root, finder.root, guide], }); const side = createSidePanels({ prefKey: "b09-store", panels: [{ id: "store", title: "재료 창고", body }], defaultWidth: 360, }); /** 내역 표 줄에 놓기 — 표를 다시 그리면 새 표에 다시 붙임 */ const dropOn = (table: HTMLElement): void => { let over: HTMLElement | null = null; const mark = (tr: HTMLElement | null): void => { over?.classList.remove("is-focus"); over = tr; over?.classList.add("is-focus"); }; table.addEventListener("dragover", (ev) => { if (!ev.dataTransfer?.types.includes(DRAG)) return; ev.preventDefault(); mark((ev.target as Element).closest("tr[data-i]")); }); table.addEventListener("dragleave", (ev) => { if (!table.contains(ev.relatedTarget as Node)) mark(null); }); table.addEventListener("drop", (ev) => { const at = ev.dataTransfer?.getData(DRAG); const tr = (ev.target as Element).closest("tr[data-i]"); mark(null); if (!at || !tr) return; ev.preventDefault(); const item = shown[Number(at)]; if (item) linkTo(item, Number(tr.dataset.i)); }); }; side.root.classList.add("b09-store__side"); return { root: side.root, bind: (next, rows, table) => { ctx = next; raw = rows; dropOn(table); draw(); }, }; } /** 설계내역서 표 오른쪽 창고 — 표(root)에 놓기 붙이고 창고 서랍을 돌려줌 */ export function attachBillStore(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { store ??= makeStore(); store.bind(ctx, raw, table); return store.root; }