feat(481-2 B08 · B09 저장 후 이동): 단계 이동 = 보이는 화면 저장 성공 뒤 · 실패면 머묾
- B08 = 왼쪽 옵션(옵션 · 이정 캐시) → 지금 탭(자재 · 수리 · 떠난 탭 host.isConnected 거짓이면 안 부름) · 캐시 없으면 요청 없이 - 저장 함수(옵션 · 자재 · 수리 · B09 run) 성공 여부 돌려줌 · B09 = 불러오기 전 · 고침 없음이면 요청 없이 이동 - 시험 test_481_2_save_move 새로 · 121 · 122 원본 줄 기대 갱신 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,12 @@ function note(text: string): HTMLElement {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderHydro(host: HTMLElement, projectId: string): Promise<void> {
|
/** `onSave` = 페이지 이동 전 저장 등록(481-2) — 고친 측점 없으면 요청 없이 참. */
|
||||||
|
export async function renderHydro(
|
||||||
|
host: HTMLElement,
|
||||||
|
projectId: string,
|
||||||
|
onSave?: (save: () => Promise<boolean>) => void,
|
||||||
|
): Promise<void> {
|
||||||
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
|
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
|
||||||
let sheet: HydroPayload;
|
let sheet: HydroPayload;
|
||||||
let interval = 20;
|
let interval = 20;
|
||||||
@@ -133,7 +138,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */
|
/** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */
|
||||||
const saveAll = async (): Promise<void> => {
|
const saveAll = async (): Promise<boolean> => {
|
||||||
const stations = Object.fromEntries(
|
const stations = Object.fromEntries(
|
||||||
sheet.stations.map((s) => [keyOf(s), unlinked(s, inputsOf(s))]),
|
sheet.stations.map((s) => [keyOf(s), unlinked(s, inputsOf(s))]),
|
||||||
);
|
);
|
||||||
@@ -148,12 +153,15 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise
|
|||||||
clearState("b08-hydro", projectId);
|
clearState("b08-hydro", projectId);
|
||||||
showToast("수리집수면적표를 저장했습니다.", "success");
|
showToast("수리집수면적표를 저장했습니다.", "success");
|
||||||
open(openIndex);
|
open(openIndex);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
open(0);
|
open(0);
|
||||||
|
onSave?.(async () => !Object.keys(drafts).length || saveAll());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { createDataTable } from "@ui/ui_template_data_table";
|
import { createDataTable } from "@ui/ui_template_data_table";
|
||||||
import { clearState, writeState } from "../A00_Common/b_page_state";
|
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||||
import {
|
import {
|
||||||
blankUnitRows,
|
blankUnitRows,
|
||||||
matched,
|
matched,
|
||||||
@@ -117,11 +117,12 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string {
|
|||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
|
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. `onSave` = 페이지 이동 전 저장 등록(481-2). */
|
||||||
export async function renderMaterialSheet(
|
export async function renderMaterialSheet(
|
||||||
host: HTMLElement,
|
host: HTMLElement,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sheet: MaterialSheetId,
|
sheet: MaterialSheetId,
|
||||||
|
onSave?: (save: () => Promise<boolean>) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
|
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
|
||||||
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
|
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
|
||||||
@@ -199,7 +200,7 @@ export async function renderMaterialSheet(
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */
|
/** [저장] — 사용자 값을 서버로 · 서버가 다시 셈한 표로 그림 · 캐시 비움 */
|
||||||
const saveAll = async (): Promise<void> => {
|
const saveAll = async (): Promise<boolean> => {
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`, {
|
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`, {
|
||||||
@@ -211,10 +212,13 @@ export async function renderMaterialSheet(
|
|||||||
clearState("b08-materials", projectId);
|
clearState("b08-materials", projectId);
|
||||||
redraw();
|
redraw();
|
||||||
showToast("자재 집계표를 저장했습니다.", "success");
|
showToast("자재 집계표를 저장했습니다.", "success");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
onSave?.(async () => !readState("b08-materials", projectId) || saveAll());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,12 @@ function hint(text: string): HTMLElement {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. */
|
/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림.
|
||||||
export function createOptionsPanel(projectId: string | null, onSaved: () => void): HTMLElement {
|
* `save` = 페이지 이동 전 저장(481-2) — 옵션 · 이정 캐시 없으면 요청 없이 참 · 실패면 거짓. */
|
||||||
|
export function createOptionsPanel(
|
||||||
|
projectId: string | null,
|
||||||
|
onSaved: () => void,
|
||||||
|
): { panel: HTMLElement; save: () => Promise<boolean> } {
|
||||||
const one = createInputField({
|
const one = createInputField({
|
||||||
label: "배관 1본 길이 (m)",
|
label: "배관 1본 길이 (m)",
|
||||||
type: "number",
|
type: "number",
|
||||||
@@ -192,12 +196,12 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
|||||||
input.addEventListener("change", show);
|
input.addEventListener("change", show);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveAll = async (): Promise<void> => {
|
const saveAll = async (): Promise<boolean> => {
|
||||||
if (!projectId) return;
|
if (!projectId) return true;
|
||||||
const draft = readDraft(projectId);
|
const draft = readDraft(projectId);
|
||||||
if (draft.one_pipe_length_m === null) {
|
if (draft.one_pipe_length_m === null) {
|
||||||
showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error");
|
showToast("배관 1본 길이를 넣으세요 — 0 보다 큰 값.", "error");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
@@ -215,8 +219,10 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
|||||||
show();
|
show();
|
||||||
showToast("수량 산출 옵션을 저장했습니다.", "success");
|
showToast("수량 산출 옵션을 저장했습니다.", "success");
|
||||||
onSaved();
|
onSaved();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
showToast(`저장하지 못했습니다 — ${String(error)}`, "error");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
@@ -256,5 +262,8 @@ export function createOptionsPanel(projectId: string | null, onSaved: () => void
|
|||||||
},
|
},
|
||||||
(error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"),
|
(error: unknown) => showToast(`옵션을 불러오지 못했습니다 — ${String(error)}`, "error"),
|
||||||
);
|
);
|
||||||
return panel;
|
const changed = (): boolean =>
|
||||||
|
!!projectId &&
|
||||||
|
(!!readState("b08-options", projectId) || !!readState("b08-milestone", projectId));
|
||||||
|
return { panel, save: async () => !changed() || saveAll() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`).
|
* 자재 아래 보조 탭 = 총괄자재수량집계표 · 콘크리트소요자재집계표 · 자재수량집계표(121-1 `…_UI_Material`).
|
||||||
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
|
* 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀.
|
||||||
* 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122).
|
* 좌측 패널 = 메인 탭과 같은 컨테이너 셋 + [저장] 줄(`B08_Quantity_UI_Options` · PLAN 122).
|
||||||
|
* 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면(좌측 옵션 + 지금 탭) 저장 성공 뒤 · 실패면 머묾(481-2).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "@ui/ui_template_general_layout.css";
|
import "@ui/ui_template_general_layout.css";
|
||||||
@@ -47,6 +48,9 @@ const SHEETS = [
|
|||||||
|
|
||||||
type SheetId = (typeof SHEETS)[number]["id"];
|
type SheetId = (typeof SHEETS)[number]["id"];
|
||||||
|
|
||||||
|
/** 지금 탭의 이동 전 저장 — 자재 · 수리 탭이 서면 등록 · 떠난 탭(`host.isConnected` 거짓)은 안 씀(481-2). */
|
||||||
|
type TabSave = (host: HTMLElement, save: () => Promise<boolean>) => void;
|
||||||
|
|
||||||
function pendingNote(): HTMLElement {
|
function pendingNote(): HTMLElement {
|
||||||
const pending = document.createElement("p");
|
const pending = document.createElement("p");
|
||||||
pending.className = "ui-general-block__empty";
|
pending.className = "ui-general-block__empty";
|
||||||
@@ -55,7 +59,7 @@ function pendingNote(): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
|
/** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
|
||||||
function renderMaterials(host: HTMLElement, projectId: string | null): void {
|
function renderMaterials(host: HTMLElement, projectId: string | null, onSave: TabSave): void {
|
||||||
const tabs = document.createElement("div");
|
const tabs = document.createElement("div");
|
||||||
tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs";
|
tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs";
|
||||||
tabs.setAttribute("role", "tablist");
|
tabs.setAttribute("role", "tablist");
|
||||||
@@ -78,17 +82,17 @@ function renderMaterials(host: HTMLElement, projectId: string | null): void {
|
|||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
view.replaceChildren(body);
|
view.replaceChildren(body);
|
||||||
if (!projectId) body.append(pendingNote());
|
if (!projectId) body.append(pendingNote());
|
||||||
else void renderMaterialSheet(body, projectId, picked);
|
else void renderMaterialSheet(body, projectId, picked, (save) => onSave(body, save));
|
||||||
};
|
};
|
||||||
host.replaceChildren(tabs, view);
|
host.replaceChildren(tabs, view);
|
||||||
select("material");
|
select("material");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
|
/** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */
|
||||||
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void {
|
function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSave: TabSave): void {
|
||||||
const view = document.createElement("div");
|
const view = document.createElement("div");
|
||||||
host.replaceChildren(view);
|
host.replaceChildren(view);
|
||||||
if (tab === "material") return renderMaterials(view, projectId);
|
if (tab === "material") return renderMaterials(view, projectId, onSave);
|
||||||
if (tab === "summary" && projectId) {
|
if (tab === "summary" && projectId) {
|
||||||
// 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐
|
// 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐
|
||||||
const blank = document.createElement("div");
|
const blank = document.createElement("div");
|
||||||
@@ -101,7 +105,8 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): voi
|
|||||||
view.append(blank, body);
|
view.append(blank, body);
|
||||||
return void renderStructureSummary(body, projectId);
|
return void renderStructureSummary(body, projectId);
|
||||||
}
|
}
|
||||||
if (tab === "hydrology" && projectId) return void renderHydro(view, projectId);
|
if (tab === "hydrology" && projectId)
|
||||||
|
return void renderHydro(view, projectId, (save) => onSave(view, save));
|
||||||
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
|
if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId);
|
||||||
if (tab === "milestone" && projectId) return renderMilestone(view, projectId);
|
if (tab === "milestone" && projectId) return renderMilestone(view, projectId);
|
||||||
view.append(pendingNote());
|
view.append(pendingNote());
|
||||||
@@ -118,6 +123,8 @@ export function renderB08Quantity(root: HTMLElement): void {
|
|||||||
panel.setAttribute("role", "tabpanel");
|
panel.setAttribute("role", "tabpanel");
|
||||||
|
|
||||||
let current: TabId = "summary";
|
let current: TabId = "summary";
|
||||||
|
let tabSave: { host: HTMLElement; save: () => Promise<boolean> } | null = null;
|
||||||
|
const onTabSave: TabSave = (host, save) => (tabSave = { host, save });
|
||||||
const select = (tab: TabId): void => {
|
const select = (tab: TabId): void => {
|
||||||
current = tab;
|
current = tab;
|
||||||
tabBar.replaceChildren(
|
tabBar.replaceChildren(
|
||||||
@@ -133,7 +140,7 @@ export function renderB08Quantity(root: HTMLElement): void {
|
|||||||
return button;
|
return button;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderTab(panel, tab, projectId);
|
renderTab(panel, tab, projectId, onTabSave);
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
@@ -141,16 +148,26 @@ export function renderB08Quantity(root: HTMLElement): void {
|
|||||||
body.append(tabBar, panel);
|
body.append(tabBar, panel);
|
||||||
select("summary");
|
select("summary");
|
||||||
|
|
||||||
|
// [저장] 뒤 열린 탭을 서버 정본으로 다시 그림
|
||||||
|
const options = createOptionsPanel(projectId, () => select(current));
|
||||||
|
// 왼쪽 → 지금 탭 차례 · 탭 저장은 왼쪽 저장이 탭을 다시 그리기 전에 쥠
|
||||||
|
const saveShown = async (): Promise<boolean> => {
|
||||||
|
const tab = tabSave?.host.isConnected ? tabSave.save : null;
|
||||||
|
return (await options.save()) && (!tab || (await tab()));
|
||||||
|
};
|
||||||
|
|
||||||
const layout = createWorkflowLayout({
|
const layout = createWorkflowLayout({
|
||||||
title: L("B08_Quantity_Title"),
|
title: L("B08_Quantity_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 5,
|
activeStep: 5,
|
||||||
mainContent: body,
|
mainContent: body,
|
||||||
// [저장] 뒤 열린 탭을 서버 정본으로 다시 그림
|
leftPanel: options.panel,
|
||||||
leftPanel: createOptionsPanel(projectId, () => select(current)),
|
|
||||||
routes: WORKFLOW_STEP_ROUTES,
|
routes: WORKFLOW_STEP_ROUTES,
|
||||||
onStepClick: (stepIndex: number) => {
|
onStepClick: (stepIndex: number) => {
|
||||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
if (!projectId) return;
|
||||||
|
void saveShown().then(
|
||||||
|
(ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
root.append(layout.root);
|
root.append(layout.root);
|
||||||
|
|||||||
@@ -275,15 +275,15 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
show(current);
|
show(current);
|
||||||
};
|
};
|
||||||
|
|
||||||
// keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림)
|
// keep = 화면 캐시 그대로(296 [업데이트] — 지운 줄 · 고친 칸을 안 되살림) · 참 = 성공
|
||||||
const run = async (
|
const run = async (
|
||||||
path: string,
|
path: string,
|
||||||
method: string,
|
method: string,
|
||||||
done: string,
|
done: string,
|
||||||
init?: Row,
|
init?: Row,
|
||||||
keep = false,
|
keep = false,
|
||||||
): Promise<void> => {
|
): Promise<boolean> => {
|
||||||
if (!projectId) return;
|
if (!projectId) return false;
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
try {
|
try {
|
||||||
const next = await request<Row>(`/projects/${projectId}${path}`, {
|
const next = await request<Row>(`/projects/${projectId}${path}`, {
|
||||||
@@ -293,18 +293,18 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
if (!keep) clearState("b09-estimation", projectId);
|
if (!keep) clearState("b09-estimation", projectId);
|
||||||
apply(next, !keep);
|
apply(next, !keep);
|
||||||
showToast(done, "success");
|
showToast(done, "success");
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`하지 못했습니다 — ${String(error)}`, "error");
|
showToast(`하지 못했습니다 — ${String(error)}`, "error");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = createButton({
|
const saveUser = (): Promise<boolean> =>
|
||||||
label: "저장",
|
run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user });
|
||||||
variant: "filled",
|
const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveUser() });
|
||||||
onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }),
|
|
||||||
});
|
|
||||||
save.dataset.b09Save = "";
|
save.dataset.b09Save = "";
|
||||||
const refresh = createButton({
|
const refresh = createButton({
|
||||||
label: "업데이트",
|
label: "업데이트",
|
||||||
@@ -352,8 +352,13 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
mainContent: body,
|
mainContent: body,
|
||||||
leftPanel: left,
|
leftPanel: left,
|
||||||
routes: WORKFLOW_STEP_ROUTES,
|
routes: WORKFLOW_STEP_ROUTES,
|
||||||
|
// 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이
|
||||||
onStepClick: (stepIndex: number) => {
|
onStepClick: (stepIndex: number) => {
|
||||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
if (!projectId) return;
|
||||||
|
const changed = payload && readState("b09-estimation", projectId);
|
||||||
|
void (changed ? saveUser() : Promise.resolve(true)).then(
|
||||||
|
(ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
root.append(layout.root);
|
root.append(layout.root);
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/* 481-2 B08 · B09 단계 이동 = 저장 성공 뒤 — 진짜 `B08_Quantity_UI_Page.ts` · `B09_Estimation_UI_Shell.ts` 를
|
||||||
|
* 트랜스파일해 가짜 DOM · 가짜 의존 위에서 돌리고 값을 JSON 으로 냄.
|
||||||
|
* 의존은 기록용 가짜 — 지정 안 한 import 는 무엇이든 받아 주는 빈 값(any). */
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
|
||||||
|
// 무엇이든 받아 주는 빈 값 — 부르면 또 빈 값 · 펼치면 비어 있음 · 글자로는 ""
|
||||||
|
function any() {
|
||||||
|
const store = {};
|
||||||
|
return new Proxy(function () {}, {
|
||||||
|
get(_t, k) {
|
||||||
|
if (k in store) return store[k];
|
||||||
|
if (k === "then") return undefined;
|
||||||
|
if (k === Symbol.iterator) return function* () {};
|
||||||
|
if (k === Symbol.toPrimitive) return () => "";
|
||||||
|
return (store[k] = any());
|
||||||
|
},
|
||||||
|
set(_t, k, v) {
|
||||||
|
store[k] = v;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
apply: () => any(),
|
||||||
|
construct: () => any(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가짜 요소 — isConnected = 뿌리까지 부모 줄이 이어짐
|
||||||
|
class Node {
|
||||||
|
constructor(tag = "div") {
|
||||||
|
this.tagName = tag.toUpperCase();
|
||||||
|
this.children = [];
|
||||||
|
this.parent = null;
|
||||||
|
this.dataset = {};
|
||||||
|
this.style = {};
|
||||||
|
this.classList = { add() {}, toggle() {}, remove() {}, contains: () => false };
|
||||||
|
}
|
||||||
|
get isConnected() {
|
||||||
|
let n = this;
|
||||||
|
while (n.parent) n = n.parent;
|
||||||
|
return n.isRoot === true;
|
||||||
|
}
|
||||||
|
append(...kids) {
|
||||||
|
kids.forEach((k) => {
|
||||||
|
if (k instanceof Node) k.parent = this;
|
||||||
|
this.children.push(k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
replaceChildren(...kids) {
|
||||||
|
this.children.forEach((k) => k instanceof Node && (k.parent = null));
|
||||||
|
this.children = [];
|
||||||
|
this.append(...kids);
|
||||||
|
}
|
||||||
|
setAttribute() {}
|
||||||
|
querySelector() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
querySelectorAll() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
global.document = { createElement: (tag) => new Node(tag) };
|
||||||
|
|
||||||
|
function load(rel, stubs) {
|
||||||
|
let src = fs
|
||||||
|
.readFileSync(path.join(ROOT, rel), "utf8")
|
||||||
|
.replace(/import\.meta\.glob\(/g, "(() => ({}))(");
|
||||||
|
const js = ts.transpileModule(src, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||||
|
}).outputText;
|
||||||
|
const mod = { exports: {} };
|
||||||
|
const req = (id) => (id in stubs ? stubs[id] : id.endsWith(".css") ? {} : any());
|
||||||
|
new Function("exports", "module", "require", js)(mod.exports, mod, req);
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
let layoutOpts = null;
|
||||||
|
const store = new Map(); // 가짜 세션 캐시 — 이름 → 값
|
||||||
|
const common = {
|
||||||
|
"@ui/ui_template_workflow_layout": {
|
||||||
|
createWorkflowLayout: (opts) => {
|
||||||
|
layoutOpts = opts;
|
||||||
|
const root = new Node();
|
||||||
|
root.append(opts.leftPanel, opts.mainContent);
|
||||||
|
return { root };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"@ui/ui_template_elements": {
|
||||||
|
createButton: (o) => Object.assign(new Node("button"), { onClick: o.onClick }),
|
||||||
|
showToast: (text, kind) => calls.push(`toast:${kind}`),
|
||||||
|
showLoadingOverlay() {},
|
||||||
|
hideLoadingOverlay() {},
|
||||||
|
showConfirmDialog: async () => false,
|
||||||
|
},
|
||||||
|
"@ui/ui_template_locale": { ui_locales: any(), currentLanguageIndex: 0 },
|
||||||
|
"@config/config_frontend": { CURRENT_PROJECT_ID_KEY: "pid", API_BASE_URL: "/api" },
|
||||||
|
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
|
||||||
|
"../A00_Common/b_workflow_nav": {
|
||||||
|
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
|
||||||
|
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
|
||||||
|
},
|
||||||
|
"../A00_Common/b_page_state": {
|
||||||
|
readState: (name) => store.get(name) ?? null,
|
||||||
|
writeState: (name, v) => store.set(name, v),
|
||||||
|
clearState: (name) => store.delete(name),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
global.localStorage = { getItem: () => "P1" };
|
||||||
|
|
||||||
|
async function b08() {
|
||||||
|
let optionsOk = true;
|
||||||
|
let tabOk = true;
|
||||||
|
let registerMaterial = null;
|
||||||
|
let registerHydro = null;
|
||||||
|
const m = load("B08_Quantity/B08_Quantity_UI_Page.ts", {
|
||||||
|
...common,
|
||||||
|
"./B08_Quantity_UI_Options": {
|
||||||
|
createOptionsPanel: () => ({
|
||||||
|
panel: new Node(),
|
||||||
|
save: async () => (calls.push("save:options"), optionsOk),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"./B08_Quantity_UI_Material": {
|
||||||
|
renderMaterialSheet: (_h, _p, _s, onSave) => (registerMaterial = onSave),
|
||||||
|
},
|
||||||
|
"./B08_Quantity_UI_Hydro": { renderHydro: (_h, _p, onSave) => (registerHydro = onSave) },
|
||||||
|
});
|
||||||
|
const root = Object.assign(new Node(), { isRoot: true });
|
||||||
|
m.renderB08Quantity(root);
|
||||||
|
const body = layoutOpts.mainContent;
|
||||||
|
const tabBar = body.children[0];
|
||||||
|
const clickTab = (id) => tabBar.children.find((b) => b.dataset.b08Tab === id).onClick();
|
||||||
|
const step = async (i) => {
|
||||||
|
calls.length = 0;
|
||||||
|
layoutOpts.onStepClick(i);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
return [...calls];
|
||||||
|
};
|
||||||
|
const tabSave = (tag) => async () => (calls.push(`save:${tag}`), tabOk);
|
||||||
|
const out = {};
|
||||||
|
out.summaryOk = await step(6);
|
||||||
|
optionsOk = false;
|
||||||
|
out.optionsFail = await step(6);
|
||||||
|
optionsOk = true;
|
||||||
|
clickTab("material");
|
||||||
|
registerMaterial(tabSave("material"));
|
||||||
|
out.materialOk = await step(4);
|
||||||
|
tabOk = false;
|
||||||
|
out.materialFail = await step(4);
|
||||||
|
optionsOk = false;
|
||||||
|
out.optionsFailSkipsTab = await step(4);
|
||||||
|
optionsOk = true;
|
||||||
|
tabOk = false;
|
||||||
|
clickTab("hydrology");
|
||||||
|
registerHydro(tabSave("hydro"));
|
||||||
|
out.hydroFail = await step(6);
|
||||||
|
clickTab("earthwork"); // 수리 탭 떠남 — 떨어진 탭 저장은 안 부름
|
||||||
|
out.leftTab = await step(6);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function b09() {
|
||||||
|
let fail = false;
|
||||||
|
global.fetch = async (url, init) => {
|
||||||
|
calls.push(`fetch:${init?.method ?? "GET"}:${url}`);
|
||||||
|
if (fail && init?.method === "PUT") return { ok: false, status: 500, json: async () => ({}) };
|
||||||
|
return { ok: true, status: 200, json: async () => any(), text: async () => "{}" };
|
||||||
|
};
|
||||||
|
const m = load("B09_Estimation/B09_Estimation_UI_Shell.ts", common);
|
||||||
|
calls.length = 0;
|
||||||
|
m.renderB09Estimation(Object.assign(new Node(), { isRoot: true }));
|
||||||
|
const step = async (i) => {
|
||||||
|
calls.length = 0;
|
||||||
|
layoutOpts.onStepClick(i);
|
||||||
|
for (let k = 0; k < 5; k++) await flush();
|
||||||
|
return calls.filter((c) => !c.startsWith("toast"));
|
||||||
|
};
|
||||||
|
const out = {};
|
||||||
|
out.beforeLoad = await (async () => {
|
||||||
|
store.set("b09-estimation", { x: 1 });
|
||||||
|
return step(5); // 불러오기 전 — 저장 없이 이동 · 캐시는 남음
|
||||||
|
})();
|
||||||
|
for (let k = 0; k < 5; k++) await flush(); // GET 응답 반영
|
||||||
|
store.delete("b09-estimation");
|
||||||
|
out.clean = await step(5);
|
||||||
|
store.set("b09-estimation", { x: 1 });
|
||||||
|
fail = true;
|
||||||
|
out.fail = await step(5);
|
||||||
|
fail = false;
|
||||||
|
out.ok = await step(5);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 진짜 좌측 옵션 `save` — 캐시 없으면 요청 없이 참 · 1본 빈칸 = 거짓 · 요청 실패 = 거짓
|
||||||
|
async function options() {
|
||||||
|
let fail = false;
|
||||||
|
const m = load("B08_Quantity/B08_Quantity_UI_Options.ts", {
|
||||||
|
...common,
|
||||||
|
"@ui/ui_template_elements": {
|
||||||
|
...common["@ui/ui_template_elements"],
|
||||||
|
createInputField: () => ({ input: new Node("input"), root: new Node() }),
|
||||||
|
},
|
||||||
|
"@ui/ui_template_collapsible": { attachCollapsible() {}, createPanelCard: () => new Node() },
|
||||||
|
"./B08_Quantity_UI_Hydro": {
|
||||||
|
request: async (url, init) => {
|
||||||
|
calls.push(`req:${init?.method ?? "GET"}`);
|
||||||
|
if (fail && init?.method === "PUT") throw new Error("500");
|
||||||
|
return { one_pipe_length_m: 6, rock_ratios_pct: { 연암: 90 }, material_surcharge_pct: {} };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"./B08_Quantity_UI_Milestone_State": {
|
||||||
|
saveMilestone: async () => calls.push("req:milestone"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const panel = m.createOptionsPanel("P1", () => calls.push("redraw"));
|
||||||
|
await flush();
|
||||||
|
const run = async () => {
|
||||||
|
calls.length = 0;
|
||||||
|
const ok = await panel.save();
|
||||||
|
return [ok, calls.filter((c) => !c.startsWith("toast"))];
|
||||||
|
};
|
||||||
|
const out = {};
|
||||||
|
store.clear();
|
||||||
|
out.clean = await run();
|
||||||
|
store.set("b08-options", { soft_rock_pct: 80 });
|
||||||
|
out.changed = await run();
|
||||||
|
out.after = store.has("b08-options");
|
||||||
|
store.set("b08-milestone", { x: 1 });
|
||||||
|
out.milestoneOnly = await run();
|
||||||
|
store.set("b08-options", { one_pipe_length_m: null });
|
||||||
|
out.blankPipe = await run();
|
||||||
|
store.set("b08-options", { soft_rock_pct: 70 });
|
||||||
|
fail = true;
|
||||||
|
out.fail = await run();
|
||||||
|
out.keptOnFail = store.has("b08-options");
|
||||||
|
store.clear();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const r = { b08: await b08(), b09: await b09(), options: await options() };
|
||||||
|
console.log(JSON.stringify(r));
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error(e && e.stack ? e.stack : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -224,7 +224,7 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def test_화면_연결():
|
def test_화면_연결():
|
||||||
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
||||||
assert "renderMaterialSheet(body, projectId, picked)" in page
|
assert "renderMaterialSheet(body, projectId, picked, (save) => onSave(body, save))" in page
|
||||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + (
|
||||||
ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts"
|
ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ def test_라우터_등록_응답_칸():
|
|||||||
def test_좌측_패널_컨테이너_셋과_저장_줄():
|
def test_좌측_패널_컨테이너_셋과_저장_줄():
|
||||||
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8")
|
||||||
panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8")
|
panel = (ROOT / "B08_Quantity/B08_Quantity_UI_Options.ts").read_text(encoding="utf-8")
|
||||||
assert "leftPanel: createOptionsPanel(projectId, () => select(current))" in page
|
assert "createOptionsPanel(projectId, () => select(current))" in page
|
||||||
|
assert "leftPanel: options.panel" in page
|
||||||
# 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재)
|
# 160-1 — 메인 탭 차례 = 구조물 집계표 · 자재 · 토공 · 수리 · 카드도 같은 차례(1본 → 집계표 · 할증 → 자재)
|
||||||
tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표
|
tabs = ("summary", "material", "earthwork", "hydrology", "milestone") # 244-1 거리이정표
|
||||||
ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page)
|
ids = re.findall(r'\{ id: "(\w+)", label: "B08_Quantity_Tab_', page)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""481-2 B08 · B09 단계 이동(☰ · 진행 단계 · [<] [>]) = 보이는 화면 저장 성공 뒤 · 실패면 머묾.
|
||||||
|
|
||||||
|
`helper_481_2_save_move.cjs` 가 진짜 `B08_Quantity_UI_Page.ts` · `B08_Quantity_UI_Options.ts` ·
|
||||||
|
`B09_Estimation_UI_Shell.ts` 를 가짜 DOM · 가짜 의존 위에서 돌림.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def r():
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", os.path.join(HERE, "helper_481_2_save_move.cjs")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_B08_저장_성공_뒤_이동_왼쪽_다음_지금_탭(r):
|
||||||
|
b = r["b08"]
|
||||||
|
assert b["summaryOk"] == ["save:options", "go:b09"]
|
||||||
|
assert b["materialOk"] == ["save:options", "save:material", "go:b07"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_B08_저장_실패면_머묾(r):
|
||||||
|
b = r["b08"]
|
||||||
|
assert b["optionsFail"] == ["save:options"]
|
||||||
|
assert b["materialFail"] == ["save:options", "save:material"]
|
||||||
|
assert b["optionsFailSkipsTab"] == ["save:options"]
|
||||||
|
assert b["hydroFail"] == ["save:options", "save:hydro"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_B08_떠난_탭은_저장_안_부름(r):
|
||||||
|
assert r["b08"]["leftTab"] == ["save:options", "go:b09"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_B08_좌측_옵션_save(r):
|
||||||
|
o = r["options"]
|
||||||
|
assert o["clean"] == [True, []] # 고침 없음 = 요청 없이 참
|
||||||
|
assert o["changed"] == [True, ["req:PUT", "req:milestone", "redraw"]]
|
||||||
|
assert o["after"] is False
|
||||||
|
assert o["milestoneOnly"][0] is True and "req:PUT" in o["milestoneOnly"][1]
|
||||||
|
assert o["blankPipe"] == [False, []] # 1본 빈칸 = 머묾
|
||||||
|
assert o["fail"] == [False, ["req:PUT"]]
|
||||||
|
assert o["keptOnFail"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_B09_고친_값_저장_성공_뒤_이동(r):
|
||||||
|
b = r["b09"]
|
||||||
|
assert b["beforeLoad"] == ["go:b08"] # 불러오기 전 = 저장 없이(캐시 남음)
|
||||||
|
assert b["clean"] == ["go:b08"] # 고침 없음 = 요청 없이
|
||||||
|
assert b["fail"] == ["fetch:PUT:/api/projects/P1/estimation"] # 실패 = 머묾
|
||||||
|
assert b["ok"] == ["fetch:PUT:/api/projects/P1/estimation", "go:b08"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_자재_수리_탭_저장_등록():
|
||||||
|
material = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||||
|
hydro = (ROOT / "B08_Quantity/B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
|
||||||
|
assert re.search(
|
||||||
|
r'onSave\?\.\(async \(\) => !readState\("b08-materials", projectId\) \|\| saveAll\(\)\)',
|
||||||
|
material,
|
||||||
|
)
|
||||||
|
assert re.search(
|
||||||
|
r"onSave\?\.\(async \(\) => !Object\.keys\(drafts\)\.length \|\| saveAll\(\)\)", hydro
|
||||||
|
)
|
||||||
|
for src in (material, hydro):
|
||||||
|
assert "const saveAll = async (): Promise<boolean>" in src
|
||||||
Reference in New Issue
Block a user