Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPrice.ts
T
eomsangdonandClaude Opus 5 aab43d652c feat(b08): 구조물도 일위대가 줄 고치기 — 줄 더하기·빼기·고르개·단가 수동, 저장 자리 둘
- 줄 조합은 양식+프로젝트(종류별), [내 라이브러리에 저장] 때 양식에 실림
- 수동 단가는 프로젝트만(값·출처·넣은 날짜) — 빨간 테두리 + 「미확정 N건」
- 고르개는 이 프로젝트 단가표에서 품셈·자원을 낱말로 찾음
- 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가도 비움

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:46:29 +09:00

191 lines
6.5 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_StructureSheet_UnitPrice.ts
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②③.
*
* ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만.
* ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임.
* ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」.
* ⚠ 수동 단가 줄은 금액 칸 빨간 테두리 + 머리 「미확정 N건」 배지(PLAN 확정 ⑦).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
import {
unitPriceEditor,
type UnitPriceEditorData,
} from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit";
interface UnitPriceRow {
seq: number;
name: string;
spec: string;
ref_code: string;
unit: string;
quantity: number | null;
skipped: boolean;
reason: string;
material?: number;
labor?: number;
expense?: number;
total?: number;
manual?: boolean;
manual_source?: string;
manual_entered_at?: string;
}
interface UnitPriceTable {
code: string;
name: string;
unit: string;
rows: UnitPriceRow[];
material: number;
labor: number;
expense: number;
total: number;
blocked: number;
complete: boolean;
unconfirmed: number;
}
/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */
function won(value: number | undefined): string {
return value === undefined ? "" : num(value, 1);
}
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */
export function unitPriceSection(
projectId: string,
sheetKey: string,
sheetRows: { no: number; name: string; unit: string }[],
): HTMLElement {
const wrap = el("div", "b08-grid");
const load = async (): Promise<void> => {
wrap.replaceChildren(
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`,
{ credentials: "include" },
);
const payload = (await response.json().catch(() => ({}))) as {
unit_price?: UnitPriceTable | null;
editor?: UnitPriceEditorData;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const table = payload.unit_price;
const editor = payload.editor;
if (!table || !editor) {
wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음"));
return;
}
const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기");
open.type = "button";
open.style.alignSelf = "flex-start";
const view = table.rows.length
? render(table)
: [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")];
open.addEventListener("click", () => {
open.disabled = true;
const panel = unitPriceEditor({
projectId,
sheetKey,
sheetRows,
data: editor,
onSaved: () => void load(),
onClose: () => {
panel.remove();
open.disabled = false;
},
});
wrap.append(panel);
});
wrap.replaceChildren(...view, open);
} catch (error) {
wrap.replaceChildren(
el(
"p",
"b08-grid__caption b08-grid__caption--warn",
`일위대가를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
}
};
void load();
return wrap;
}
function render(table: UnitPriceTable): HTMLElement[] {
const total = table.complete
? `${num(table.total, 0)}원`
: `미완 — 막힌 줄 ${table.blocked} · 선 줄만 ${num(table.total, 0)}원`;
const head = el(
"p",
"b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
);
if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
}
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table b08-grid__table--summary");
const headRow = document.createElement("tr");
for (const label of [
"공종·자원",
"코드",
"수량",
"단위",
"재료비",
"노무비",
"경비",
"합계",
"비고",
]) {
headRow.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const manual = row.manual
? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}`
: "";
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : manual;
// 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음.
const spec =
row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : "";
const money = [row.material, row.labor, row.expense, row.total].map((value) => {
const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value));
if (row.manual) cell.title = manual;
return cell;
});
tr.append(
el("td", "", spec ? `${row.name} (${spec})` : row.name),
el("td", "", row.ref_code),
el("td", "", row.quantity === null ? "" : num(row.quantity, 3)),
el("td", "", row.unit),
...money,
el("td", "", note),
);
tbody.append(tr);
}
const foot = document.createElement("tr");
foot.append(
el("td", "", "계"),
el("td", "", ""),
el("td", "", ""),
el("td", "", ""),
el("td", "", won(table.material)),
el("td", "", won(table.labor)),
el("td", "", won(table.expense)),
el("td", "", table.complete ? num(table.total, 0) : `미완 ${num(table.total, 0)}`),
el("td", "", ""),
);
tbody.append(foot);
grid.append(thead, tbody);
scroller.append(grid);
return [head, scroller];
}