- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수) - 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음 - 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억) — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서 - 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀 - 시험 4건 보탬 · 전체 1723 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tab_Progress.ts
|
|
* 기성 탭 — STmate 「기성 제잡비 계산서」(wM_KanJub_K)와 기성내역서(계약|전회|금회|누계)를 본뜸 (PLAN 12장 · 랩탑 메인).
|
|
*
|
|
* - 좌측 = 회차 고르기 · [회차 추가] · 부가세 방식 넷(직접입력이면 금액) · [저장].
|
|
* - 본문 위 = 기성 제잡비 계산서 — 도급액 · 계약잡비율 · 전회/금회/누계 금액과 율.
|
|
* - 본문 아래 = 기성내역 — 계약 · 전회 · **금회 기성수량 칸** · 누계 · 기성(%) · 잔량.
|
|
* - ⚠ 값은 서버(`/estimation/progress`)가 계약내역·계약 원가계산서에서 파생 — 계약은 안 바뀜.
|
|
* - ⚠ 기성 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음.
|
|
* ========================================================================== */
|
|
|
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
type Column = "previous" | "current" | "cumulative";
|
|
const COLUMNS: [Column, string, string][] = [
|
|
["previous", "전회금액", "전율"],
|
|
["current", "금회금액", "금율"],
|
|
["cumulative", "누계금액", "누율"],
|
|
];
|
|
|
|
interface Round {
|
|
quantities: Record<string, string>;
|
|
vat_mode: string;
|
|
vat_manual_krw: string;
|
|
supply_cut_krw: string;
|
|
total_cut_krw: string;
|
|
profit_manual_krw: string;
|
|
/** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */
|
|
assessed: Record<string, string>;
|
|
}
|
|
|
|
type Override = { rate_pct: string; reason: string };
|
|
type Overrides = Record<string, Override>;
|
|
type Choice = { key: string; label: string };
|
|
|
|
interface AmountRow {
|
|
key: string;
|
|
name: string;
|
|
contract_krw: string;
|
|
contract_ratio_pct?: string | null;
|
|
default_ratio_pct?: string;
|
|
override?: Override | null;
|
|
[column: string]: string | null | undefined | Override;
|
|
}
|
|
|
|
interface ProgressRow {
|
|
item_no: string;
|
|
name: string;
|
|
spec: string;
|
|
unit: string;
|
|
quantity: string | null;
|
|
is_group: boolean;
|
|
in_bill: boolean;
|
|
contract_amount_krw?: string;
|
|
progress_pct?: string | null;
|
|
progress_remaining_quantity?: string;
|
|
progress_note?: string;
|
|
[column: string]: string | boolean | null | undefined;
|
|
}
|
|
|
|
interface ProgressDto {
|
|
status: string;
|
|
message?: string;
|
|
rows: ProgressRow[];
|
|
items: AmountRow[];
|
|
summary: AmountRow[];
|
|
contract_overhead_ratio_pct: string | null;
|
|
round: number;
|
|
round_count: number;
|
|
notes: string[];
|
|
settings: { rounds: Round[]; rate_overrides: Overrides };
|
|
fields: { vat: Choice[]; supply_cut: Choice[]; total_cut: Choice[] };
|
|
limit_note: string;
|
|
}
|
|
|
|
const STYLE_ID = "b09-progress-styles";
|
|
function injectStyles(): void {
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
.b09pg { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
|
|
.b09pg__meta { font-size: 12px; color: var(--color-text-secondary); }
|
|
.b09pg__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
|
|
.b09pg__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
|
.b09pg__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
|
|
.b09pg__table th, .b09pg__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
|
|
.b09pg__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.b09pg__table tr.is-group td, .b09pg__table tr.is-total td { font-weight: 600; }
|
|
.b09pg__table input { width: 7em; text-align: right; }
|
|
.b09pg__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
|
.b09pg__panel label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
`;
|
|
document.head.append(style);
|
|
}
|
|
|
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
className = "",
|
|
text = "",
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function won(value: string | boolean | null | undefined): string {
|
|
if (value === null || value === undefined || value === "" || typeof value === "boolean")
|
|
return "";
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
|
|
}
|
|
|
|
function pct(value: string | boolean | null | undefined): string {
|
|
return typeof value === "string" ? `${value}%` : "";
|
|
}
|
|
|
|
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */
|
|
const drafts = new Map<string, { rounds: Round[]; overrides: Overrides; round: number }>();
|
|
|
|
function draftOf(projectId: string, data: ProgressDto) {
|
|
let draft = drafts.get(projectId);
|
|
if (!draft) {
|
|
draft = {
|
|
rounds: structuredClone(data.settings.rounds),
|
|
overrides: structuredClone(data.settings.rate_overrides),
|
|
round: data.round,
|
|
};
|
|
drafts.set(projectId, draft);
|
|
}
|
|
return draft;
|
|
}
|
|
|
|
function endpoint(projectId: string): string {
|
|
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/progress`;
|
|
}
|
|
|
|
async function fetchProgress(projectId: string, round: number | null): Promise<ProgressDto> {
|
|
const query = round ? `?round=${round}` : "";
|
|
const response = await fetch(`${endpoint(projectId)}${query}`, { credentials: "include" });
|
|
const body = (await response.json()) as ProgressDto;
|
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
|
return body;
|
|
}
|
|
|
|
async function saveProgress(
|
|
projectId: string,
|
|
rounds: Round[],
|
|
overrides: Overrides,
|
|
): Promise<void> {
|
|
const response = await fetch(endpoint(projectId), {
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rounds, rate_overrides: overrides }),
|
|
});
|
|
const body = (await response.json()) as { message?: string };
|
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
|
}
|
|
|
|
function choiceSelect(choices: Choice[], value: string, onChange: (v: string) => void) {
|
|
const node = el("select");
|
|
for (const choice of choices) {
|
|
const option = el("option", "", choice.label);
|
|
option.value = choice.key;
|
|
node.append(option);
|
|
}
|
|
node.value = value;
|
|
node.addEventListener("change", () => onChange(node.value));
|
|
return node;
|
|
}
|
|
|
|
/** 공급가액 절사 · 총공사비 절사(9택) · 이윤금액 직접입력 — 떨어지는 몫은 이윤에서. */
|
|
function drawCuts(data: ProgressDto, current: Round): HTMLElement[] {
|
|
const supply = el("label");
|
|
supply.append(
|
|
el("span", "", "공급가액 절사"),
|
|
choiceSelect(
|
|
data.fields.supply_cut,
|
|
current.supply_cut_krw,
|
|
(v) => (current.supply_cut_krw = v),
|
|
),
|
|
);
|
|
const total = el("label");
|
|
const profit = el("input");
|
|
profit.type = "number";
|
|
profit.min = "0";
|
|
profit.placeholder = "이윤금액(비우면 계약잡비율 값)";
|
|
profit.value = current.profit_manual_krw;
|
|
profit.hidden = current.total_cut_krw !== "";
|
|
profit.addEventListener("input", () => (current.profit_manual_krw = profit.value));
|
|
total.append(
|
|
el("span", "", "총공사비 절사"),
|
|
choiceSelect(data.fields.total_cut, current.total_cut_krw, (v) => {
|
|
current.total_cut_krw = v;
|
|
profit.hidden = v !== "";
|
|
}),
|
|
profit,
|
|
);
|
|
return [supply, total];
|
|
}
|
|
|
|
function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void {
|
|
const projectId = ctx.projectId as string;
|
|
const draft = draftOf(projectId, data);
|
|
const box = el("div", "b09pg__panel");
|
|
const save = async (round: number): Promise<void> => {
|
|
try {
|
|
await saveProgress(projectId, draft.rounds, draft.overrides);
|
|
drafts.delete(projectId);
|
|
showToast("기성 회차 저장 — 계약 내역은 그대로", "success");
|
|
reload(round);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
|
|
}
|
|
};
|
|
const pick = el("label");
|
|
pick.append(el("span", "", "회차"));
|
|
const select = el("select");
|
|
for (let n = 1; n <= draft.rounds.length; n += 1) {
|
|
const option = el("option", "", `${n}회`);
|
|
option.value = String(n);
|
|
select.append(option);
|
|
}
|
|
select.value = String(data.round);
|
|
select.addEventListener("change", () => reload(Number(select.value)));
|
|
pick.append(select);
|
|
box.append(pick);
|
|
box.append(
|
|
createButton({
|
|
label: "회차 추가(저장)",
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
const last = draft.rounds[draft.rounds.length - 1];
|
|
draft.rounds.push({
|
|
quantities: {},
|
|
vat_mode: last?.vat_mode ?? "supply",
|
|
vat_manual_krw: "",
|
|
supply_cut_krw: last?.supply_cut_krw ?? "",
|
|
total_cut_krw: last?.total_cut_krw ?? "",
|
|
profit_manual_krw: "",
|
|
assessed: {},
|
|
});
|
|
void save(draft.rounds.length);
|
|
},
|
|
}),
|
|
);
|
|
if (draft.rounds.length) {
|
|
box.append(
|
|
createButton({
|
|
label: `${draft.rounds.length}회 지우기(저장)`,
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
// 앞 회차를 지우면 뒤 회차의 전회가 통째로 바뀜 — 마지막 회차만 지움.
|
|
draft.rounds.pop();
|
|
void save(draft.rounds.length);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const current = draft.rounds[data.round - 1];
|
|
if (current) {
|
|
const vat = el("label");
|
|
vat.append(el("span", "", "부가세"));
|
|
const mode = el("select");
|
|
for (const choice of data.fields.vat) {
|
|
const option = el("option", "", choice.label);
|
|
option.value = choice.key;
|
|
mode.append(option);
|
|
}
|
|
mode.value = current.vat_mode;
|
|
const manual = el("input");
|
|
manual.type = "number";
|
|
manual.min = "0";
|
|
manual.placeholder = "직접입력 금액";
|
|
manual.value = current.vat_manual_krw;
|
|
manual.hidden = current.vat_mode !== "manual";
|
|
mode.addEventListener("change", () => {
|
|
current.vat_mode = mode.value;
|
|
manual.hidden = mode.value !== "manual";
|
|
});
|
|
manual.addEventListener("input", () => (current.vat_manual_krw = manual.value));
|
|
vat.append(mode, manual);
|
|
box.append(vat, ...drawCuts(data, current));
|
|
box.append(createButton({ label: "저장", onClick: () => save(data.round) }));
|
|
} else {
|
|
box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터"));
|
|
}
|
|
ctx.panel.append(box);
|
|
}
|
|
|
|
/** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */
|
|
function drawJab(data: ProgressDto, overrides: Overrides): HTMLElement {
|
|
const box = el("div");
|
|
box.append(
|
|
el(
|
|
"strong",
|
|
"",
|
|
`기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`,
|
|
),
|
|
el(
|
|
"div",
|
|
"b09pg__meta",
|
|
"계약잡비율 기본 = 역산(도급액 ÷ 계약 직접공사비) · 계약서 값이 다르면 직접입력 + 사유 → [저장] · ↺ 로 역산값",
|
|
),
|
|
);
|
|
const table = el("table", "b09pg__table");
|
|
const head = el("tr");
|
|
for (const label of [
|
|
"명칭",
|
|
"도급액",
|
|
"계약잡비율",
|
|
"직접입력(%)",
|
|
"사유",
|
|
"",
|
|
...COLUMNS.flatMap(([, a, r]) => [a, r]),
|
|
]) {
|
|
head.append(el("th", "", label));
|
|
}
|
|
table.append(head);
|
|
const line = (row: AmountRow, total: boolean): void => {
|
|
const tr = el("tr", total ? "is-total" : "");
|
|
const ratio = el("td", "num", pct(row.contract_ratio_pct));
|
|
if (row.override) ratio.title = `역산값 ${row.default_ratio_pct}% — 직접입력이 이김`;
|
|
tr.append(el("td", "", row.name), el("td", "num", won(row.contract_krw)), ratio);
|
|
const cells = [el("td"), el("td"), el("td")];
|
|
if (!total) {
|
|
const entry = (): Override => (overrides[row.key] ??= { rate_pct: "", reason: "" });
|
|
const rate = el("input");
|
|
rate.type = "number";
|
|
rate.min = "0";
|
|
rate.step = "any";
|
|
rate.placeholder = row.default_ratio_pct ?? "";
|
|
rate.value = overrides[row.key]?.rate_pct ?? "";
|
|
rate.addEventListener("input", () => (entry().rate_pct = rate.value));
|
|
const reason = el("input");
|
|
reason.placeholder = "사유(계약서 등)";
|
|
reason.style.width = "12em";
|
|
reason.style.textAlign = "left";
|
|
reason.value = overrides[row.key]?.reason ?? "";
|
|
reason.addEventListener("input", () => (entry().reason = reason.value));
|
|
const revert = el("button", "", "↺");
|
|
revert.type = "button";
|
|
revert.title = "역산값으로 되돌림 — [저장]하면 반영";
|
|
revert.disabled = !overrides[row.key];
|
|
revert.addEventListener("click", () => {
|
|
delete overrides[row.key];
|
|
rate.value = "";
|
|
reason.value = "";
|
|
});
|
|
cells[0].append(rate);
|
|
cells[1].append(reason);
|
|
cells[2].append(revert);
|
|
}
|
|
tr.append(...cells);
|
|
for (const [col] of COLUMNS) {
|
|
const text = (key: string): string | null => {
|
|
const value = row[key];
|
|
return typeof value === "string" ? value : null;
|
|
};
|
|
tr.append(el("td", "num", won(text(`${col}_krw`))), el("td", "num", pct(text(`${col}_pct`))));
|
|
}
|
|
table.append(tr);
|
|
};
|
|
for (const row of data.items) line(row, false);
|
|
for (const row of data.summary) line(row, true);
|
|
box.append(table);
|
|
return box;
|
|
}
|
|
|
|
function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement {
|
|
const box = el("div");
|
|
box.append(el("strong", "", `기성내역 — ${data.round ? `${data.round}회` : "회차 없음"}`));
|
|
const table = el("table", "b09pg__table");
|
|
const head = el("tr");
|
|
for (const label of [
|
|
"공종번호",
|
|
"명칭",
|
|
"규격",
|
|
"단위",
|
|
"계약수량",
|
|
"계약금액",
|
|
"전회수량",
|
|
"전회금액",
|
|
"금회수량",
|
|
"금회금액",
|
|
"사정(칸만)",
|
|
"누계수량",
|
|
"누계금액",
|
|
"기성(%)",
|
|
"잔량",
|
|
"비고",
|
|
]) {
|
|
head.append(el("th", "", label));
|
|
}
|
|
table.append(head);
|
|
const current = rounds[data.round - 1];
|
|
for (const row of data.rows) {
|
|
const tr = el("tr", row.is_group ? "is-group" : "");
|
|
const leaf = !row.is_group && row.in_bill && row.progress_current_quantity !== undefined;
|
|
tr.append(
|
|
el("td", "", row.item_no),
|
|
el("td", "", row.name),
|
|
el("td", "", row.spec ?? ""),
|
|
el("td", "", row.unit ?? ""),
|
|
el("td", "num", leaf ? (row.quantity ?? "") : ""),
|
|
el("td", "num", won(row.contract_amount_krw)),
|
|
el("td", "num", leaf ? String(row.progress_previous_quantity) : ""),
|
|
el("td", "num", won(row.progress_previous_amount_krw)),
|
|
);
|
|
const cell = el("td");
|
|
if (leaf && current) {
|
|
const qty = el("input");
|
|
qty.type = "number";
|
|
qty.min = "0";
|
|
qty.step = "any";
|
|
qty.value = current.quantities[row.item_no] ?? "";
|
|
qty.addEventListener("input", () => {
|
|
if (qty.value === "") delete current.quantities[row.item_no];
|
|
else current.quantities[row.item_no] = qty.value;
|
|
});
|
|
cell.append(qty);
|
|
}
|
|
// 사정 — 뜻 미확인(「기성내역서(사정)」 열 이름뿐) · 계산에 안 씀.
|
|
const assessed = el("td");
|
|
if (leaf && current) {
|
|
const amount = el("input");
|
|
amount.type = "number";
|
|
amount.min = "0";
|
|
amount.title = "사정 — 뜻 확인 대기 · 금액에 안 섞임";
|
|
amount.value = current.assessed[row.item_no] ?? "";
|
|
amount.addEventListener("input", () => {
|
|
if (amount.value === "") delete current.assessed[row.item_no];
|
|
else current.assessed[row.item_no] = amount.value;
|
|
});
|
|
assessed.append(amount);
|
|
}
|
|
tr.append(
|
|
cell,
|
|
el("td", "num", won(row.progress_current_amount_krw)),
|
|
assessed,
|
|
el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""),
|
|
el("td", "num", won(row.progress_cumulative_amount_krw)),
|
|
el("td", "num", pct(row.progress_pct)),
|
|
el("td", "num", row.progress_remaining_quantity ?? ""),
|
|
el("td", "", row.progress_note ?? ""),
|
|
);
|
|
table.append(tr);
|
|
}
|
|
box.append(table);
|
|
return box;
|
|
}
|
|
|
|
function drawBody(ctx: B09TabContext, data: ProgressDto): void {
|
|
const draft = draftOf(ctx.projectId as string, data);
|
|
const wrap = el("div", "b09pg");
|
|
wrap.append(el("div", "b09pg__warn", `⚠ ${data.limit_note}`));
|
|
for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`));
|
|
wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영"));
|
|
const scroll = el("div", "b09pg__scroll");
|
|
scroll.append(drawJab(data, draft.overrides), drawBill(data, draft.rounds));
|
|
wrap.append(scroll);
|
|
ctx.body.append(wrap);
|
|
}
|
|
|
|
function render(ctx: B09TabContext): void {
|
|
injectStyles();
|
|
if (!ctx.projectId) {
|
|
ctx.body.append(el("div", "b09pg__meta", "프로젝트를 고르세요"));
|
|
return;
|
|
}
|
|
const load = (round: number | null): void => {
|
|
ctx.body.replaceChildren(
|
|
el("div", "b09pg__meta", `${L("B09_Estimation_Tab_Progress")} 계산 중…`),
|
|
);
|
|
ctx.panel.replaceChildren();
|
|
fetchProgress(ctx.projectId as string, round)
|
|
.then((data) => {
|
|
const draft = drafts.get(ctx.projectId as string);
|
|
if (draft) draft.round = data.round;
|
|
ctx.body.replaceChildren();
|
|
drawPanel(ctx, data, load);
|
|
drawBody(ctx, data);
|
|
})
|
|
.catch((error: unknown) => {
|
|
ctx.body.replaceChildren(
|
|
el(
|
|
"div",
|
|
"b09pg__warn",
|
|
`기성을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
|
|
),
|
|
);
|
|
});
|
|
};
|
|
load(drafts.get(ctx.projectId)?.round ?? null);
|
|
}
|
|
|
|
export const progressTab: B09Tab = {
|
|
key: "progress",
|
|
label: () => L("B09_Estimation_Tab_Progress"),
|
|
render,
|
|
};
|