Files
Aislo/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts
T

180 lines
6.0 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;
}
interface MaterialPricesDto {
status: string;
message?: string;
rows: MaterialPriceRow[];
unconfirmed_count: number;
}
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 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",
row.missing
? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움"
: row.work_items.join(", "),
),
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.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음"));
ctx.body.append(
hint(
"수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 — 자재단가대비표에도 보임",
),
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));
});
},
};