Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
358 lines
13 KiB
TypeScript
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;
|
|
}
|