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