Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts
T

206 lines
7.2 KiB
TypeScript

/* =============================================================================
* B09_Estimation_UI_Tab_MaterialPrices.ts
* 자재 단가 탭 — 자재 수동 단가 칸 (PLAN 1장 Ⓐ · 랩탑 메인 · 2026-09-14 브레인 판정)
*
* - 줄 = 일위대가 자원 축이 쓰는 자재(코드 `AR-M-…`) · 코드 · 명칭 · 규격 · 단위 · 쓰인 공종 ·
* 단가 · 출처 · 넣은 날. 넣은 줄 = 빨간 테두리(수동 단가 = 미확정 · 구조물도·폐기물과 같은 꼴).
* - [저장] = 바뀐 줄만 서버로. 단가 칸을 비우면 그 줄을 지움(단가 없음으로 돌아감) · 0 이하는 서버가 거절.
* - ⚠ 금액은 서버가 다시 조립 — 여기서 곱하지 않음. 저장 뒤 내역서·일위대가 탭은 새로 받음.
* - 자원 축에서 사라진 코드의 저장 줄도 조용히 안 버림 — 경고와 함께 보이고 지울 수 있음.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet";
interface MaterialPriceRow {
key: string;
name: string;
spec: string;
unit: string;
work_items: string[];
price_krw: string | null;
source: string;
entered_at: string;
missing: boolean;
/** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */
origin?: string;
supply_type?: string;
quantity?: string;
}
interface MaterialPricesDto {
status: string;
message?: string;
rows: MaterialPriceRow[];
unconfirmed_count: number;
handoff_note?: string;
}
const SUPPLY_LABELS: Record<string, string> = {
contractor_supplied: "사급",
owner_supplied: "관급 — 도급 금액 밖(수량만)",
unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)",
};
function whereText(row: MaterialPriceRow): string {
if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움";
if (row.origin === "material_sheet") {
return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${
SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? ""
}`;
}
return `일위대가 · ${row.work_items.join(", ")}`;
}
interface Change {
key: string;
price_krw: string;
source: string;
name: string;
spec: string;
unit: string;
}
async function request(projectId: string, init: RequestInit = {}): Promise<MaterialPricesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`,
{ credentials: "include", ...init },
);
const body = (await response.json()) as MaterialPricesDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
function input(value: string, width: string, placeholder = ""): HTMLInputElement {
const node = el("input", "b09s-edit-input");
node.value = value;
node.placeholder = placeholder;
node.style.width = width;
return node;
}
function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void {
const drafts: Array<{
row: MaterialPriceRow;
price: HTMLInputElement;
source: HTMLInputElement;
}> = [];
const save = el("button", "b09s-undo", "저장");
save.type = "button";
const bar = el("div", "b09s-bar");
bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save);
if (data.unconfirmed_count > 0) {
bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`));
}
const { wrap, tbody } = plainTable([
"코드·키",
"명칭",
"규격",
"단위",
"쓰이는 곳",
"단가(원)",
"출처",
"넣은 날",
]);
for (const row of data.rows) {
const tr = el("tr", row.price_krw ? "is-manual" : "");
const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음");
price.inputMode = "decimal";
price.title = row.price_krw ? won(row.price_krw) : "";
const source = input(row.source, "160px", "견적 업체·물가지 쪽");
// 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게).
const locked =
row.origin === "material_sheet" &&
row.supply_type !== "contractor_supplied" &&
!row.price_krw;
price.disabled = locked;
source.disabled = locked;
const priceCell = el("td", "b09s-num");
priceCell.append(price);
const sourceCell = el("td");
sourceCell.append(source);
tr.append(
el("td", "", row.key),
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
el("td", "b09s-note", whereText(row)),
priceCell,
sourceCell,
el("td", "", row.entered_at),
);
tbody.append(tr);
drafts.push({ row, price, source });
}
save.addEventListener("click", () => {
const changes: Change[] = drafts
.filter(
({ row, price, source }) =>
price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source,
)
.map(({ row, price, source }) => ({
key: row.key,
price_krw: price.value.trim(),
source: source.value.trim(),
name: row.name,
spec: row.spec,
unit: row.unit,
}));
if (changes.length === 0) return;
save.disabled = true;
request(projectId, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
})
.then((next) => {
showToast(L("B09_Sheet_Saved"), "success");
ctx.body.replaceChildren();
draw(ctx, projectId, next);
})
.catch((error: Error) => {
save.disabled = false;
showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error");
});
});
ctx.body.append(bar, wrap);
if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true));
if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음"));
ctx.body.append(
hint(
"일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임",
),
hint(
"자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖",
),
hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"),
);
}
export const materialPricesTab: B09Tab = {
key: "material_prices",
label: () => L("B09_Estimation_Tab_MaterialPrices"),
render(ctx) {
injectSheetStyles();
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
const projectId = ctx.projectId;
ctx.body.append(hint(L("B09_Sheet_Loading")));
request(projectId)
.then((data) => {
ctx.body.replaceChildren();
draw(ctx, projectId, data);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};