/* ============================================================================= * B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts * 구조물도 일위대가 **줄 고치기** — 줄 더하기·빼기 · 고르개(품셈·자원 찾기) · 수동 단가. PLAN 3장 ③. * * ⚠ 저장 자리는 둘(브레인 판정) — 줄 조합은 양식+프로젝트, 수동 단가는 프로젝트만. 한 번의 [저장]이 * 둘을 함께 보내고 서버가 갈라 적음. [내 라이브러리에 저장]은 줄 조합만 실음. * ⚠ 금액은 여기서 셈하지 않음 — 저장 뒤 표를 다시 받아 서버 계산(B09 단가표)으로 그림. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "./B08_Quantity_UI_StructureSheet_Formula"; export interface UnitPriceSpecRow { seq: number; name?: string; spec?: string; unit?: string; from_row?: number; quantity?: number; ref_code?: string; work_item_code?: string; variant_from?: string; sub_vars?: Record; } export interface ManualPrice { material: number; labor: number; expense: number; source: string; entered_at?: string; } export interface UnitPriceEditorData { rows: UnitPriceSpecRow[]; default_rows: UnitPriceSpecRow[]; edited: boolean; manual_prices: Record; } interface SearchItem { code: string; name: string; spec: string; unit: string; price: number | null; } interface EditorOptions { projectId: string; sheetKey: string; sheetRows: { no: number; name: string; unit: string }[]; data: UnitPriceEditorData; onSaved: () => void; onClose: () => void; } const MONEY: [keyof Omit, string][] = [ ["material", "재료비"], ["labor", "노무비"], ["expense", "경비"], ]; function sheetUrl(projectId: string, tail: string): string { return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets${tail}`; } function input(value: string | number | undefined, width: string, type = "text"): HTMLInputElement { const node = document.createElement("input"); node.type = type; node.value = value === undefined ? "" : String(value); node.style.width = width; return node; } function codeText(row: UnitPriceSpecRow): string { if (row.ref_code) return row.ref_code; if (row.work_item_code) { return row.variant_from ? `${row.work_item_code} (갈래: ${row.variant_from})` : row.work_item_code; } return "코드 없음"; } /** 줄 고치기 칸 — 로컬에서만 고치다 [저장]에 서버로. */ export function unitPriceEditor(options: EditorOptions): HTMLElement { const { projectId, sheetKey, sheetRows, data, onSaved, onClose } = options; let rows: UnitPriceSpecRow[] = structuredClone(data.rows); const manual: Record = structuredClone(data.manual_prices); let picking: number | null = null; const wrap = el("div", "b08-unit-edit"); const tbody = document.createElement("tbody"); const picker = el("div", "b08-unit-edit__picker"); const status = el("span", "b08-grid__caption"); const render = (): void => { tbody.replaceChildren(...rows.map((row, index) => rowOf(row, index))); picker.hidden = picking === null; }; const rowOf = (row: UnitPriceSpecRow, index: number): HTMLTableRowElement => { const tr = document.createElement("tr"); const name = input(row.name, "9rem"); name.addEventListener("input", () => (row.name = name.value || undefined)); // 수량 — 원단위 줄을 따르거나 박힌 값. const source = document.createElement("select"); source.append(new Option("박힌 값", "")); for (const line of sheetRows) { source.append(new Option(`원단위 ${line.no}. ${line.name} (${line.unit})`, String(line.no))); } source.value = row.from_row ? String(row.from_row) : ""; const fixed = input(row.quantity, "5rem", "number"); fixed.step = "any"; fixed.disabled = Boolean(row.from_row); source.addEventListener("change", () => { row.from_row = source.value ? Number(source.value) : undefined; if (row.from_row) delete row.quantity; fixed.disabled = Boolean(row.from_row); }); fixed.addEventListener("input", () => { row.quantity = fixed.value === "" ? undefined : Number(fixed.value); }); const quantity = el("td", ""); quantity.append(source, fixed); const unit = input(row.unit, "3rem"); unit.addEventListener("input", () => (row.unit = unit.value || undefined)); const code = el("td", "", codeText(row)); const find = el("button", "", "찾기"); find.type = "button"; find.title = "품셈·자원을 찾아 이 줄의 코드로 넣음"; find.addEventListener("click", () => { picking = index; render(); // 코드가 아직 없는 줄은 그 줄 이름으로 **후보만** 띄움 — 고르는 것은 사용자(명세 2장 · 이름 자동 확정 금지). if (!row.ref_code && !row.work_item_code && row.name) { query.value = row.name; void search(); } picker.querySelector("input")?.focus(); }); code.append(" ", find); // 수동 단가 — 넣으면 빨간 테두리 · 표 머리에 「미확정」으로 셈. 프로젝트에만 저장됨. const price = el("td", ""); const key = String(row.seq); const on = document.createElement("input"); on.type = "checkbox"; on.checked = key in manual; on.title = "수동 단가 — 이 프로젝트에만 저장(라이브러리로 안 감)"; price.append(on, " 수동"); const fields = MONEY.map(([field, label]) => { const box = input(manual[key]?.[field], "5.5rem", "number"); box.min = "0"; box.step = "any"; box.placeholder = label; box.title = `${label} 단가(단위당)`; box.addEventListener("input", () => { if (manual[key]) manual[key][field] = Number(box.value || 0); }); return box; }); const origin = input(manual[key]?.source, "7rem"); origin.placeholder = "출처"; origin.addEventListener("input", () => { if (manual[key]) manual[key].source = origin.value; }); const sync = (): void => { for (const box of [...fields, origin]) { box.disabled = !on.checked; box.classList.toggle("b08-unit__manual", on.checked); } }; on.addEventListener("change", () => { if (on.checked) { manual[key] = { material: 0, labor: 0, expense: 0, source: "" }; fields.forEach((box, i) => (manual[key][MONEY[i][0]] = Number(box.value || 0))); manual[key].source = origin.value; } else { delete manual[key]; } sync(); }); sync(); price.append(...fields, origin); if (manual[key]?.entered_at) price.append(` ${manual[key].entered_at}`); const remove = el("button", "", "빼기"); remove.type = "button"; remove.addEventListener("click", () => { rows = rows.filter((_, i) => i !== index); delete manual[key]; picking = null; render(); }); const actions = el("td", ""); actions.append(remove); const nameCell = el("td", ""); nameCell.append(name); const unitCell = el("td", ""); unitCell.append(unit); tr.append(el("td", "", String(row.seq)), nameCell, quantity, unitCell, code, price, actions); return tr; }; // 고르개 — 이 프로젝트 단가표에서 낱말로 찾음. const kind = document.createElement("select"); kind.append(new Option("품셈(일위대가)", "work"), new Option("자원(자재·노임·중기)", "resource")); const query = input("", "12rem"); query.placeholder = "이름·코드·규격 낱말"; const go = el("button", "", "찾기"); go.type = "button"; const shut = el("button", "", "닫기"); shut.type = "button"; const results = el("div", "b08-unit-edit__results"); const search = async (): Promise => { if (!query.value.trim()) return; results.replaceChildren(el("span", "b08-grid__caption", "찾는 중…")); try { const response = await fetch( sheetUrl( projectId, `/price-search?q=${encodeURIComponent(query.value)}&kind=${kind.value}`, ), { credentials: "include" }, ); const payload = (await response.json()) as { items?: SearchItem[]; message?: string }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); const items = payload.items ?? []; results.replaceChildren( ...(items.length ? items.map(choice) : [ // 후보 0 — 조용히 막히지 않게 다음 길을 적음(갈래 바꿔 찾기 · 수동 단가는 미확정으로 셈). el( "span", "b08-grid__caption", "단가표에 후보 없음 — 낱말·갈래(품셈/자원)를 바꿔 찾거나, 이 줄에 수동 단가를 넣을 것(미확정으로 셈)", ), ]), ); } catch (error) { results.replaceChildren( el("span", "b08-grid__caption", `못 찾음 — ${error instanceof Error ? error.message : ""}`), ); } }; const choice = (item: SearchItem): HTMLElement => { const price = item.price === null ? "단가 안 섬" : `${item.price.toLocaleString("ko-KR")}원`; const button = el( "button", "", `${item.code} · ${item.name}${item.spec ? ` ${item.spec}` : ""} · ${item.unit} · ${price}`, ); button.type = "button"; button.addEventListener("click", () => { const row = picking === null ? undefined : rows[picking]; if (!row) return; row.ref_code = item.code; row.name = item.name; row.spec = item.spec || undefined; row.unit = item.unit || undefined; delete row.work_item_code; delete row.variant_from; delete row.sub_vars; picking = null; render(); }); return button; }; go.addEventListener("click", () => void search()); query.addEventListener("keydown", (event) => { if (event.key === "Enter") void search(); }); shut.addEventListener("click", () => { picking = null; render(); }); picker.append(kind, query, go, shut, results); const grid = el("table", "b08-grid__table"); const head = document.createElement("tr"); for (const label of ["차례", "이름", "수량", "단위", "코드", "단가 수동(단위당)", ""]) { head.append(el("th", "", label)); } const thead = document.createElement("thead"); thead.append(head); grid.append(thead, tbody); const scroller = el("div", "b08-grid__scroll"); scroller.append(grid); const add = el("button", "", "줄 더하기"); add.type = "button"; add.addEventListener("click", () => { // 양식 줄 차례와도 안 겹치게 — 뺀 양식 줄의 차례를 새 줄이 물려받지 않음. const seq = Math.max(0, ...rows.map((r) => r.seq), ...data.default_rows.map((r) => r.seq)) + 1; rows.push({ seq, name: "", quantity: 1 }); render(); }); const reset = el("button", "", "양식대로"); reset.type = "button"; reset.title = "줄 조합을 양식 원래대로 — [저장]해야 반영"; reset.addEventListener("click", () => { rows = structuredClone(data.default_rows); picking = null; render(); }); const save = el("button", "b08-spec__save", "일위대가 저장"); save.type = "button"; save.addEventListener("click", () => { void (async () => { save.disabled = true; status.textContent = "저장 중…"; try { // 넣은 날짜는 서버가 붙임 — 값·출처만 보냄. const prices = Object.fromEntries( Object.entries(manual).map(([seq, { entered_at: _date, ...price }]) => [seq, price]), ); const response = await fetch(sheetUrl(projectId, "/unit-price"), { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sheet_key: sheetKey, rows, manual_prices: prices }), }); const payload = (await response.json().catch(() => ({}))) as { message?: string }; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); onSaved(); } catch (error) { status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`; save.disabled = false; } })(); }); const close = el("button", "b08-quantity__tab", "고친 것 버리고 닫기"); close.type = "button"; close.addEventListener("click", onClose); const actions = el("div", "b08-sheet__actions"); actions.append(add, save, reset, close, status); wrap.append( el( "p", "b08-grid__caption", "줄 조합은 이 양식+프로젝트에 저장(내 라이브러리로 갈 수 있음) · 수동 단가는 이 프로젝트에만 저장", ), scroller, picker, actions, ); render(); return wrap; }