feat(b09): 요율 덮어쓰기 — 프로젝트 단위 · 칸마다 사유 · 기본값↔고친 값 · ↺ 되돌리기
- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림 - 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」 - 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨) - PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지 - 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림 - 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
* B09_Estimation_UI_Tab_RateTable.ts
|
||||
* 제비율 요율표 탭 — STmate 「원가계산기준」(wM_KAN_RATE)을 본뜸 (PLAN 12장 · 랩탑 메인).
|
||||
*
|
||||
* - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` (지금은 잠김 — 덮어쓰기 칸 전).
|
||||
* - 좌측 = 도구줄 자리: `수정기준선택 ☞` [기본제비율] · `수 정` · `취 소` · `저 장`.
|
||||
* - 본문 = 첫 탭 「☞ 공사원가계산 제잡비율」 묶음 차례 · 아래 탭 넷(첫 탭만 켜짐).
|
||||
* - ⚠ 요율은 서버(`/estimation/rate-table`)가 요율 데이터에서 그대로 냄 — 보기 전용.
|
||||
* - 요율 덮어쓰기 = **이 프로젝트만** · 칸마다 사유 필수 · 「기본값 ↔ 고친 값」 갈라 보임 · ↺ 되돌리기.
|
||||
* - ⚠ 값은 서버가 냄 — 화면은 칸 주소와 새 값·사유만 돌려줌. 마스터 요율표는 안 건드림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
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";
|
||||
|
||||
@@ -16,11 +17,30 @@ function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
interface RateAddress {
|
||||
variable: string;
|
||||
table: string | null;
|
||||
match: Record<string, string | number>;
|
||||
}
|
||||
|
||||
interface RateCell {
|
||||
value: number | null;
|
||||
default: number | null;
|
||||
overridden: boolean;
|
||||
address: RateAddress;
|
||||
}
|
||||
|
||||
interface RateSection {
|
||||
title: string;
|
||||
formula: string;
|
||||
columns: string[];
|
||||
rows: (string | number)[][];
|
||||
rows: (string | number | RateCell)[][];
|
||||
}
|
||||
|
||||
interface Override extends RateAddress {
|
||||
rate_percent: string;
|
||||
reason: string;
|
||||
entered_at?: string;
|
||||
}
|
||||
|
||||
interface RateTableDto {
|
||||
@@ -28,6 +48,17 @@ interface RateTableDto {
|
||||
message?: string;
|
||||
rate_version: { dataset_id: string; effective_date: string; sha256: string };
|
||||
sections: RateSection[];
|
||||
overrides: Override[];
|
||||
override_error: string;
|
||||
}
|
||||
|
||||
/** 고치는 중인 칸 — 저장 전 캐시(지침 5장). */
|
||||
interface DraftEntry {
|
||||
address: RateAddress;
|
||||
value: string;
|
||||
reason: string;
|
||||
label: string;
|
||||
default: number | null;
|
||||
}
|
||||
|
||||
/** DFM 아래 탭 — 화면에 보이는 묶음 제목 그대로. */
|
||||
@@ -46,18 +77,26 @@ function injectStyles(): void {
|
||||
style.textContent = `
|
||||
.b09rt { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
|
||||
.b09rt__meta { font-size: 12px; color: var(--color-text-secondary); }
|
||||
.b09rt__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
|
||||
.b09rt__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.b09rt__section { border: 1px solid var(--color-border); padding: 6px 8px; }
|
||||
.b09rt__title { font-weight: 600; font-size: 13px; }
|
||||
.b09rt__formula { font-size: 12px; color: var(--color-text-secondary); margin-left: 6px; font-weight: normal; }
|
||||
.b09rt__table { border-collapse: collapse; font-size: 12px; margin-top: 4px; }
|
||||
.b09rt__table th, .b09rt__table td { border: 1px solid var(--color-border); padding: 2px 8px; }
|
||||
.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.b09rt__table td:first-child, .b09rt__table td.b09rt__text { text-align: left; }
|
||||
.b09rt__table td { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.b09rt__table td.b09rt__text { text-align: left; }
|
||||
.b09rt__table td.is-changed { outline: 2px solid var(--color-accent, #6c8ebf); outline-offset: -2px; }
|
||||
.b09rt__default { font-size: 11px; color: var(--color-text-secondary); margin-left: 4px; }
|
||||
.b09rt__table input { width: 5em; text-align: right; }
|
||||
.b09rt__tabs { display: flex; gap: 2px; border-top: 1px solid var(--color-border); padding-top: 4px; }
|
||||
.b09rt__tab { font-size: 12px; padding: 2px 10px; border: 1px solid var(--color-border); border-top: none; background: none; }
|
||||
.b09rt__tab[aria-selected="true"] { font-weight: 600; }
|
||||
.b09rt__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||
.b09rt__change { display: flex; flex-direction: column; gap: 2px; border-top: 1px dotted var(--color-border); padding-top: 4px; }
|
||||
.b09rt__change input { width: 100%; }
|
||||
.b09rt__change input.is-empty { outline: 2px solid var(--color-danger, #d9534f); }
|
||||
.b09rt__buttons { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -73,6 +112,18 @@ function el<K extends keyof HTMLElementTagNameMap>(
|
||||
return node;
|
||||
}
|
||||
|
||||
function addressKey(address: RateAddress): string {
|
||||
const match = Object.keys(address.match)
|
||||
.sort()
|
||||
.map((k) => `${k}=${address.match[k]}`)
|
||||
.join("&");
|
||||
return `${address.variable}|${address.table ?? ""}|${match}`;
|
||||
}
|
||||
|
||||
function isCell(value: unknown): value is RateCell {
|
||||
return typeof value === "object" && value !== null && "address" in value;
|
||||
}
|
||||
|
||||
async function fetchRates(projectId: string): Promise<RateTableDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`,
|
||||
@@ -83,7 +134,86 @@ async function fetchRates(projectId: string): Promise<RateTableDto> {
|
||||
return body;
|
||||
}
|
||||
|
||||
function drawPanel(ctx: B09TabContext): void {
|
||||
async function saveOverrides(projectId: string, overrides: Override[]): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/rate-table`,
|
||||
{
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overrides }),
|
||||
},
|
||||
);
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
/** 프로젝트별 탭 상태 — 탭을 다시 골라도 [저장] 전 고친 칸이 남음. */
|
||||
const states = new Map<string, { editing: boolean; draft: Map<string, DraftEntry> }>();
|
||||
|
||||
function stateOf(projectId: string, data: RateTableDto) {
|
||||
let state = states.get(projectId);
|
||||
if (!state) {
|
||||
state = { editing: false, draft: new Map() };
|
||||
states.set(projectId, state);
|
||||
}
|
||||
if (!state.editing) {
|
||||
// 편집 중이 아니면 저장본으로 다시 채움 — 저장본이 곧 정본.
|
||||
state.draft = new Map(
|
||||
data.overrides.map((o) => [
|
||||
addressKey(o),
|
||||
{
|
||||
address: { variable: o.variable, table: o.table, match: o.match },
|
||||
value: o.rate_percent,
|
||||
reason: o.reason,
|
||||
label: "",
|
||||
default: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function render(ctx: B09TabContext): void {
|
||||
injectStyles();
|
||||
if (!ctx.projectId) {
|
||||
ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요"));
|
||||
return;
|
||||
}
|
||||
const projectId = ctx.projectId;
|
||||
const load = (): void => {
|
||||
ctx.body.replaceChildren(el("div", "b09rt__meta", "요율표 받는 중…"));
|
||||
fetchRates(projectId)
|
||||
.then((data) => {
|
||||
const state = stateOf(projectId, data);
|
||||
const redraw = (): void => {
|
||||
ctx.body.replaceChildren();
|
||||
ctx.panel.replaceChildren();
|
||||
drawBody(ctx, data, state, redraw);
|
||||
drawPanel(ctx, state, redraw, load);
|
||||
};
|
||||
redraw();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.body.replaceChildren(
|
||||
el(
|
||||
"div",
|
||||
"b09rt__meta",
|
||||
`요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
load();
|
||||
}
|
||||
|
||||
function drawPanel(
|
||||
ctx: B09TabContext,
|
||||
state: { editing: boolean; draft: Map<string, DraftEntry> },
|
||||
redraw: () => void,
|
||||
reload: () => void,
|
||||
): void {
|
||||
const box = el("div", "b09rt__panel");
|
||||
const pick = el("label", "b09rt__panel");
|
||||
pick.append(el("span", "", "수정기준선택 ☞"));
|
||||
@@ -94,30 +224,119 @@ function drawPanel(ctx: B09TabContext): void {
|
||||
select.append(option);
|
||||
}
|
||||
pick.append(select);
|
||||
const edit = createButton({ label: "수 정", variant: "ghost", disabled: true });
|
||||
edit.title = "요율 수정은 원가계산이 프로젝트 요율을 받는 칸이 선 뒤에 열림";
|
||||
box.append(pick);
|
||||
|
||||
const buttons = el("div", "b09rt__buttons");
|
||||
if (!state.editing) {
|
||||
buttons.append(
|
||||
createButton({
|
||||
label: "수 정",
|
||||
onClick: () => {
|
||||
state.editing = true;
|
||||
redraw();
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
buttons.append(
|
||||
createButton({
|
||||
label: "취 소",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
state.editing = false;
|
||||
reload();
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: "저 장",
|
||||
onClick: async () => {
|
||||
const entries = [...state.draft.values()];
|
||||
if (entries.some((entry) => !entry.reason.trim())) {
|
||||
showToast("고친 요율마다 사유를 적어야 저장됨", "error");
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveOverrides(
|
||||
ctx.projectId as string,
|
||||
entries.map((entry) => ({
|
||||
...entry.address,
|
||||
rate_percent: entry.value,
|
||||
reason: entry.reason.trim(),
|
||||
})),
|
||||
);
|
||||
state.editing = false;
|
||||
showToast("요율 덮어쓰기 저장 — 이 프로젝트만", "success");
|
||||
reload();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "저장 못 함", "error");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
box.append(buttons);
|
||||
box.append(
|
||||
pick,
|
||||
edit,
|
||||
el("span", "b09rt__meta", "요율은 조달청 제비율 판 그대로 — 지금은 보기 전용"),
|
||||
createButton({
|
||||
label: "원가계산서",
|
||||
variant: "ghost",
|
||||
onClick: () => ctx.open("cost_sheet"),
|
||||
}),
|
||||
el(
|
||||
"span",
|
||||
"b09rt__meta",
|
||||
"고친 요율은 이 프로젝트에만 걸림 — 마스터 요율표(조달청 판)는 그대로 · 칸마다 사유 필수",
|
||||
),
|
||||
);
|
||||
|
||||
// 고친 칸 목록 — 사유 칸(편집 중에만 고침).
|
||||
const changes = [...state.draft.values()];
|
||||
box.append(el("div", "b09rt__title", `고친 요율 ${changes.length}건`));
|
||||
for (const entry of changes) {
|
||||
const row = el("div", "b09rt__change");
|
||||
row.append(
|
||||
el(
|
||||
"span",
|
||||
"",
|
||||
`${entry.label || entry.address.variable} — ${entry.default ?? "?"} → ${entry.value}`,
|
||||
),
|
||||
);
|
||||
if (state.editing) {
|
||||
const reason = el("input");
|
||||
reason.placeholder = "사유(필수) — 예: 발주처 지침 ○○호";
|
||||
reason.value = entry.reason;
|
||||
reason.classList.toggle("is-empty", !entry.reason.trim());
|
||||
reason.addEventListener("input", () => {
|
||||
entry.reason = reason.value;
|
||||
reason.classList.toggle("is-empty", !reason.value.trim());
|
||||
});
|
||||
row.append(reason);
|
||||
} else {
|
||||
row.append(el("span", "b09rt__meta", `사유: ${entry.reason}`));
|
||||
}
|
||||
box.append(row);
|
||||
}
|
||||
box.append(
|
||||
createButton({ label: "원가계산서", variant: "ghost", onClick: () => ctx.open("cost_sheet") }),
|
||||
);
|
||||
ctx.panel.append(box);
|
||||
}
|
||||
|
||||
function drawBody(ctx: B09TabContext, data: RateTableDto): void {
|
||||
function drawBody(
|
||||
ctx: B09TabContext,
|
||||
data: RateTableDto,
|
||||
state: { editing: boolean; draft: Map<string, DraftEntry> },
|
||||
redraw: () => void,
|
||||
): void {
|
||||
const wrap = el("div", "b09rt");
|
||||
wrap.append(
|
||||
el(
|
||||
"div",
|
||||
"b09rt__meta",
|
||||
`원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})`,
|
||||
`원가계산기준 — 요율 판 ${data.rate_version.effective_date} (${data.rate_version.dataset_id})` +
|
||||
(state.editing ? " · 수정 중" : ""),
|
||||
),
|
||||
);
|
||||
if (data.override_error) {
|
||||
wrap.append(
|
||||
el("div", "b09rt__warn", `⚠ 저장된 덮어쓰기가 지금 판과 안 맞음 — ${data.override_error}`),
|
||||
);
|
||||
}
|
||||
const scroll = el("div", "b09rt__scroll");
|
||||
for (const section of data.sections) {
|
||||
const box = el("div", "b09rt__section");
|
||||
@@ -130,9 +349,20 @@ function drawBody(ctx: B09TabContext, data: RateTableDto): void {
|
||||
table.append(head);
|
||||
for (const row of section.rows) {
|
||||
const tr = el("tr");
|
||||
for (const cell of row) {
|
||||
tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell)));
|
||||
}
|
||||
const rowLabel = row.filter((c) => typeof c === "string" && c).join(" ");
|
||||
row.forEach((cell, index) => {
|
||||
if (!isCell(cell)) {
|
||||
tr.append(el("td", typeof cell === "number" ? "" : "b09rt__text", String(cell)));
|
||||
return;
|
||||
}
|
||||
const key = addressKey(cell.address);
|
||||
const entry = state.draft.get(key);
|
||||
if (entry) {
|
||||
entry.label = `${section.title} · ${rowLabel} · ${section.columns[index]}`;
|
||||
entry.default = cell.default;
|
||||
}
|
||||
tr.append(rateCell(cell, entry, state, section, rowLabel, index, redraw));
|
||||
});
|
||||
table.append(tr);
|
||||
}
|
||||
box.append(table);
|
||||
@@ -151,28 +381,58 @@ function drawBody(ctx: B09TabContext, data: RateTableDto): void {
|
||||
ctx.body.append(wrap);
|
||||
}
|
||||
|
||||
function render(ctx: B09TabContext): void {
|
||||
injectStyles();
|
||||
if (!ctx.projectId) {
|
||||
ctx.body.append(el("div", "b09rt__meta", "프로젝트를 고르세요"));
|
||||
return;
|
||||
/** 요율 칸 — 보기: 값(+기본값) · 편집: 입력 + ↺. 기본값과 같아지면 덮어쓰기에서 빠짐. */
|
||||
function rateCell(
|
||||
cell: RateCell,
|
||||
entry: DraftEntry | undefined,
|
||||
state: { editing: boolean; draft: Map<string, DraftEntry> },
|
||||
section: RateSection,
|
||||
rowLabel: string,
|
||||
index: number,
|
||||
redraw: () => void,
|
||||
): HTMLTableCellElement {
|
||||
const td = el("td");
|
||||
const key = addressKey(cell.address);
|
||||
const shown = entry ? entry.value : String(cell.value ?? "");
|
||||
const changed = Boolean(entry) && Number(shown) !== cell.default;
|
||||
td.classList.toggle("is-changed", changed);
|
||||
if (entry?.reason) td.title = `기본 ${cell.default} · 사유: ${entry.reason}`;
|
||||
if (!state.editing) {
|
||||
td.append(document.createTextNode(shown));
|
||||
if (changed) td.append(el("span", "b09rt__default", `(기본 ${cell.default})`));
|
||||
return td;
|
||||
}
|
||||
drawPanel(ctx);
|
||||
ctx.body.append(el("div", "b09rt__meta", "요율표 받는 중…"));
|
||||
fetchRates(ctx.projectId)
|
||||
.then((data) => {
|
||||
ctx.body.replaceChildren();
|
||||
drawBody(ctx, data);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
ctx.body.replaceChildren(
|
||||
el(
|
||||
"div",
|
||||
"b09rt__meta",
|
||||
`요율표를 받지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||
),
|
||||
);
|
||||
const input = el("input");
|
||||
input.type = "number";
|
||||
input.step = "0.001";
|
||||
input.min = "0";
|
||||
input.value = shown;
|
||||
input.addEventListener("change", () => {
|
||||
if (input.value === "" || Number(input.value) === cell.default) {
|
||||
state.draft.delete(key);
|
||||
} else {
|
||||
state.draft.set(key, {
|
||||
address: cell.address,
|
||||
value: input.value,
|
||||
reason: entry?.reason ?? "",
|
||||
label: `${section.title} · ${rowLabel} · ${section.columns[index]}`,
|
||||
default: cell.default,
|
||||
});
|
||||
}
|
||||
redraw();
|
||||
});
|
||||
td.append(input);
|
||||
if (entry) {
|
||||
const undo = el("button", "", "↺");
|
||||
undo.type = "button";
|
||||
undo.title = `기본값(${cell.default})으로 — [저 장]하면 덮어쓰기에서 빠짐`;
|
||||
undo.addEventListener("click", () => {
|
||||
state.draft.delete(key);
|
||||
redraw();
|
||||
});
|
||||
td.append(undo);
|
||||
}
|
||||
return td;
|
||||
}
|
||||
|
||||
export const rateTableTab: B09Tab = {
|
||||
|
||||
Reference in New Issue
Block a user