feat(B09): 단가연결 즉시 금액(뒤에서 서버 셈) · [저장] 단추 · 옮길 때 물음(PLAN 702-3)

- 새 산근(로직 연결 · 입력값) = 연결 즉시 뒤에서 POST …/estimation/calc(화면 캐시로 셈 ·
  저장 안 함) · 차례 번호 · 옛 요청 끊기 · 오는 동안 그 줄 금액 칸만 「셈 중」 · 잠금 없음
  (새 `B09_Estimation_UI_Live.ts`) · 기준 = payload.user(서버가 그 셈에 쓴 값)
- 조작 중 셈 = 마지막 GET · [저장] 의 B08 원천 다시 씀(120 s · 0.6 → 0.15 s) · [저장] · GET 은 늘 새로 읽음
- [저장] 단추 = 왼쪽 아래 [초기화] 옆(B08 과 같은 부품 · 꼴) · 고침 없으면 알림만
- 단계 이동 · [>] = 저장 안 한 고침 있으면 공용 떠남 물음(취소 · 폐기 · 저장) — 옛 자동 저장 없앰
- 서고 사본 payload 의 user 받음(서버 기준) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈
- 낡은 시험 고침: 481-2 · 298-2(자동 저장 → 물음 · [저장] 단추) · 184-3 · 304(단추 줄) ·
  204-2(user 받음) · 200-2(701-3 띠 · 702-1 펼침 뒤 셸 꼴)
- 확인 = test_702_3 3 · B09 관련 312 통과 · typecheck 0 · 검증_sub5 T6 11 누름 · 연결 뒤 0.32~0.49 s 금액

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:11:44 +09:00
co-authored by Claude Opus 5.5
parent 1599014504
commit 59a694a7ed
16 changed files with 399 additions and 48 deletions
+48 -19
View File
@@ -4,8 +4,9 @@
*
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 저장(PUT → 서버 정본) = 단계 이동 · [>] 때(481-2 · [저장] 단추 없음 · 요구 301)
* [>] = 고친 값 저장 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · 새 산근(로직 연결) = 뒤에서 서버 셈(702-3 · `…_UI_Live`) ·
* [저장](702-3 · 왼쪽 아래 [초기화] 옆) = PUT → 서버 정본으로 다시 셈 · 저장 안 하고 단계 옮김 · [>] = 물음(저장 · 버림 · 머묾)
* [>] = 물음 → POST …/estimation/complete(단계 6 완료) → `DRAFT.next`(인쇄 미리보기 · 초안) · 실패면 머묾
* [초기화](요구 298-2) = 확인 창 → POST …/estimation/reset(사용자 값 · 단가 책 지움 · 견적서 남김) → 셈 값 · [업데이트] = POST …/prices
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
* ========================================================================== */
@@ -36,6 +37,7 @@ import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
import { computeTotal } from "./B09_Estimation_Calc_Total";
import { createTabBars } from "./B09_Estimation_UI_TabBar";
import { createCheckPanel } from "./B09_Estimation_UI_Check";
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
@@ -56,7 +58,11 @@ import {
renderGroupPanel,
unpickOnRelease,
} from "./B09_Estimation_UI_Tabs_Groups";
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
import {
askProjectLeave,
currentProjectId,
guardProjectLeave,
} from "../A00_Common/b_project_switch";
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
import.meta.glob("./draft/*_UI.ts", { eager: true });
@@ -139,22 +145,37 @@ export function renderB09Estimation(root: HTMLElement): void {
body.className = "b09-estimation";
body.append(bars.top, bars.sub, panel);
// 702-3 새 산근 = 뒤에서 서버 셈 → 받은 답을 같은 자료 묶음에 끼움(열린 창이 쥔 payload 그대로)
const live = projectId
? createLiveCalc(projectId, (next, sent) => {
if (next && payload) Object.assign(payload, next, { user: sent });
redraw();
})
: null;
const recompute = (): void => {
if (!payload) return;
sheets = computeSheets(payload, user);
const wait = waitingKeys(payload, user);
live?.kick(wait, user);
sheets = computeSheets(withoutWaiting(payload, wait), user);
if (live?.busy()) markWaiting(sheets, wait);
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
};
const setUser = (next: Row): void => {
user = next;
if (projectId) writeState("b09-estimation", user, projectId);
redraw();
};
function redraw(): void {
recompute();
// 고친 뒤 다시 그려도 보던 자리 지킴(166-2 내역서 수량 · 손 줄)
const top = panel.querySelector<HTMLElement>(".ui-data-table")?.scrollTop ?? 0;
show(current);
const box = panel.querySelector<HTMLElement>(".ui-data-table");
if (box && top) box.scrollTop = top;
};
}
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
const show = (tab: TabId, focus?: string): void => {
@@ -342,11 +363,22 @@ export function renderB09Estimation(root: HTMLElement): void {
}),
});
reset.dataset.b09Reset = "";
// 702-3 [저장] — 화면 캐시 → 서버 정본으로 다시 셈(고침 없으면 요청 없이 알림)
const save = createButton({
label: L("Common_Btn_Save"),
variant: "filled",
onClick: () => {
if (!projectId || readState("b09-estimation", projectId) === null)
return void showToast("저장할 고침이 없습니다.", "info");
void saveChanged();
},
});
save.dataset.b09Save = "";
// 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식)
const section = createPanelCard("데이터 점검", true); // 311 기본 접힘
section.append(refresh, check.list);
// 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][<][>][초기화]
const actions = createDock([reset], () => isGroupTab(current) && show(current));
const actions = createDock([save, reset], () => isGroupTab(current) && show(current));
const left = document.createElement("div");
left.className = "ui-sidebar-panel b09-options";
left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions);
@@ -359,15 +391,15 @@ export function renderB09Estimation(root: HTMLElement): void {
mainContent: body,
leftPanel: left,
routes: WORKFLOW_STEP_ROUTES,
// 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이
// 702-3 단계 이동 = 저장 안 한 고침 있으면 물음(저장 · 버림 · 머묾) 뒤 · 고침 없음 = 바로
onStepClick: (stepIndex: number) => {
if (!projectId) return;
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [
{ name: "원가 저장", run: saveChanged },
]);
void askProjectLeave().then((go) => {
if (go) void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], []);
});
},
});
// [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로
// [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 물음(702-3) → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로
const next = left.querySelector<HTMLButtonElement>('[data-step-nav="1"]');
const goNext = DRAFT.next;
if (next && goNext) next.disabled = false;
@@ -376,14 +408,11 @@ export function renderB09Estimation(root: HTMLElement): void {
event.stopPropagation();
const why = DRAFT.blocked?.();
if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만
void moveStage(projectId, goNext, [
{ name: "원가 저장", run: saveChanged },
{
name: "원가 계산 단계 완료",
run: async () =>
void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })),
},
]);
const complete = async (): Promise<void> =>
void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" }));
void askProjectLeave().then((go) => {
if (go) void moveStage(projectId, goNext, [{ name: "원가 계산 단계 완료", run: complete }]);
});
});
root.append(layout.root);
show(current);