Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts

358 lines
13 KiB
TypeScript

/* =============================================================================
* 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<string, number | string>;
}
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<string, ManualPrice>;
}
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<ManualPrice, "source" | "entered_at">, 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<string, ManualPrice> = 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<void> => {
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;
}