Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-08 18:42:57 +09:00
10 changed files with 209 additions and 18 deletions
+31 -1
View File
@@ -51,9 +51,33 @@ export interface UploadStatusResponse {
completed_chunk_indexes: number[]; completed_chunk_indexes: number[];
} }
/**
* 「지우고 진행할까?」를 물어야 하는 상태(409). 서버가 무엇이 지워지는지 함께 준다.
* 되돌릴 수 없는 삭제라 사람이 한 번 보고 정한다(2026-09-08 사용자 지시).
*/
export class ReplaceOutputsConfirmRequired extends Error {
readonly targets: string[];
constructor(message: string, targets: string[]) {
super(message);
this.name = "ReplaceOutputsConfirmRequired";
this.targets = targets;
}
}
async function readJsonOrThrow<T>(response: Response): Promise<T> { async function readJsonOrThrow<T>(response: Response): Promise<T> {
const payload = (await response.json()) as T & { message?: string }; const payload = (await response.json()) as T & {
message?: string;
confirm?: string;
targets?: string[];
};
if (!response.ok) { if (!response.ok) {
if (response.status === 409 && payload.confirm === "replace_outputs") {
throw new ReplaceOutputsConfirmRequired(
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
payload.targets ?? [],
);
}
throw new Error(payload.message ?? `HTTP ${response.status}`); throw new Error(payload.message ?? `HTTP ${response.status}`);
} }
return payload; return payload;
@@ -62,9 +86,11 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
export async function uploadProjectFiles( export async function uploadProjectFiles(
projectId: string, projectId: string,
files: readonly File[], files: readonly File[],
confirmReplace = false,
): Promise<FileUploadResponse> { ): Promise<FileUploadResponse> {
const formData = new FormData(); const formData = new FormData();
for (const file of files) formData.append("files", file, file.name); for (const file of files) formData.append("files", file, file.name);
if (confirmReplace) formData.append("confirm_replace", "true");
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
@@ -88,6 +114,7 @@ export async function createUploadSession(
fingerprint?: string | null, fingerprint?: string | null,
completeUpload = false, completeUpload = false,
lasFree = false, lasFree = false,
confirmReplace = false,
): Promise<ChunkSessionCreateResponse> { ): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST", method: "POST",
@@ -100,6 +127,7 @@ export async function createUploadSession(
fingerprint: fingerprint ?? null, fingerprint: fingerprint ?? null,
complete_upload: completeUpload, complete_upload: completeUpload,
las_free: lasFree, las_free: lasFree,
confirm_replace: confirmReplace,
}), }),
}); });
return await readJsonOrThrow<ChunkSessionCreateResponse>(response); return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
@@ -131,6 +159,7 @@ export async function finalizeUploadSession(
completeUpload: boolean, completeUpload: boolean,
fingerprint?: string | null, fingerprint?: string | null,
lasFree = false, lasFree = false,
confirmReplace = false,
): Promise<FileUploadResponse> { ): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST", method: "POST",
@@ -142,6 +171,7 @@ export async function finalizeUploadSession(
complete_upload: completeUpload, complete_upload: completeUpload,
fingerprint: fingerprint ?? null, fingerprint: fingerprint ?? null,
las_free: lasFree, las_free: lasFree,
confirm_replace: confirmReplace,
}), }),
}); });
return await readJsonOrThrow<FileUploadResponse>(response); return await readJsonOrThrow<FileUploadResponse>(response);
+10 -1
View File
@@ -59,6 +59,10 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import (
_SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES, _SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES,
) )
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
)
from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import (
_already_uploaded as _already_uploaded, _already_uploaded as _already_uploaded,
) )
@@ -132,6 +136,7 @@ async def upload_project_files(
project_id: UUID, project_id: UUID,
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
las_free: bool = Form(False), las_free: bool = Form(False),
confirm_replace: bool = Form(False),
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse: ) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다.""" """프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
@@ -258,7 +263,7 @@ async def upload_project_files(
) )
) )
point_cloud_input_id = await _complete_file_input_if_ready( point_cloud_input_id = await _complete_file_input_if_ready(
connection, project_id, las_free connection, project_id, las_free, confirm_replace
) )
await connection.commit() await connection.commit()
except Exception: except Exception:
@@ -282,6 +287,10 @@ async def upload_project_files(
task_name=f"b04-preprocess-auto-{project_id}", task_name=f"b04-preprocess-auto-{project_id}",
) )
return FileUploadResponse(project_id=str(project_id), files=results) return FileUploadResponse(project_id=str(project_id), files=results)
except OutputsWouldBeDiscarded as exc:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
return _confirm_replace_response(exc)
except LookupError as exc: except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
@@ -35,8 +35,10 @@ from B03_FileInput.B03_FileInput_Repository import (
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import (
_ANALYSIS_RUNNING_MESSAGE, _ANALYSIS_RUNNING_MESSAGE,
OutputsWouldBeDiscarded,
_already_uploaded, _already_uploaded,
_complete_file_input_if_ready, _complete_file_input_if_ready,
_confirm_replace_response,
_schedule_background_task, _schedule_background_task,
_total_chunks, _total_chunks,
_write_stage_metadata, _write_stage_metadata,
@@ -110,6 +112,7 @@ async def create_project_upload_session(
connection, connection,
project_id, project_id,
payload.las_free, payload.las_free,
payload.confirm_replace,
) )
await connection.commit() await connection.commit()
except Exception: except Exception:
@@ -144,6 +147,8 @@ async def create_project_upload_session(
chunk_size_bytes=chunk_size_bytes, chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks, total_chunks=total_chunks,
) )
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc: except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
@@ -202,6 +207,8 @@ async def upload_project_chunk(
total_chunks=int(upload_session["total_chunks"]), total_chunks=int(upload_session["total_chunks"]),
chunk_hash=chunk_hash, chunk_hash=chunk_hash,
) )
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc: except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
@@ -300,6 +307,7 @@ async def finalize_project_upload(
connection, connection,
project_id, project_id,
payload.las_free, payload.las_free,
payload.confirm_replace,
) )
await connection.commit() await connection.commit()
except Exception: except Exception:
@@ -329,6 +337,8 @@ async def finalize_project_upload(
task_name=f"b04-preprocess-auto-{project_id}", task_name=f"b04-preprocess-auto-{project_id}",
) )
return FileUploadResponse(project_id=str(project_id), files=[result]) return FileUploadResponse(project_id=str(project_id), files=[result])
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc: except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
@@ -373,6 +383,8 @@ async def get_project_upload_status(
completed_chunks=len(completed_indexes), completed_chunks=len(completed_indexes),
completed_chunk_indexes=completed_indexes, completed_chunk_indexes=completed_indexes,
) )
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc: except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
@@ -12,6 +12,7 @@ from typing import Any
from uuid import UUID from uuid import UUID
import aiomysql import aiomysql
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Email import ( from B03_FileInput.B03_FileInput_Email import (
send_file_upload_complete_email, send_file_upload_complete_email,
@@ -40,6 +41,61 @@ logger = logging.getLogger(__name__)
_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요." _ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요."
# 새 자료를 받으면 옛 산출물을 지운다 — 지워질 것이 있으면 **먼저 묻는다**(2026-09-08).
# 창 넷이 한 프로젝트를 함께 볼 수 있어, 말없이 지우면 남이 며칠 만든 설계가 사라진다.
# 되돌릴 길이 없다(초기값 스냅숏까지 함께 버린다).
_OUTPUT_STAGE_LABELS: tuple[tuple[str, str], ...] = (
("B04_PreProcess", "지표면·전처리 결과"),
("B05_Profile", "노선·종단 설계"),
("B06_Section", "횡단 설계"),
("B07_DesignDetail", "도면"),
("B08_Quantity", "수량 산출"),
("B09_Estimation", "원가·내역"),
)
class OutputsWouldBeDiscarded(Exception):
"""이미 있는 설계 산출물을 지워야 새 자료를 받을 수 있는 상태.
라우터가 409 로 돌려주고, 사람이 「지우고 진행」을 고르면 `confirm_replace=True`
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
"""
def __init__(self, targets: list[str]) -> None:
self.targets = targets
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
"""「지우고 진행할까?」를 사람에게 묻는 409 — 무엇이 지워지는지 함께 알린다."""
return JSONResponse(
status_code=409,
content={
"status": "confirm_required",
"confirm": "replace_outputs",
"message": (
"이 프로젝트에는 이미 만들어 둔 결과가 있습니다. 새 자료로 갈면 "
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
),
"targets": exc.targets,
},
)
def describe_existing_outputs(project_root: Path) -> list[str]:
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
targets: list[str] = []
for stage, label in _OUTPUT_STAGE_LABELS:
stage_root = project_root / stage
if not stage_root.is_dir():
continue
if any(entry.is_file() for entry in stage_root.rglob("*")):
targets.append(label)
if (project_root / "initial_snapshot").is_dir():
targets.append("초기값 스냅숏(되돌리기의 기준)")
return targets
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) _REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) _POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). # 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
@@ -149,7 +205,13 @@ async def _complete_file_input_if_ready(
connection: aiomysql.Connection, connection: aiomysql.Connection,
project_id: UUID, project_id: UUID,
las_free: bool = False, las_free: bool = False,
confirm_replace: bool = True,
) -> int: ) -> int:
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
"""
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id connection, project_id
) )
@@ -172,6 +234,11 @@ async def _complete_file_input_if_ready(
gap_message = merge_gap_error(terrain_paths) gap_message = merge_gap_error(terrain_paths)
if gap_message: if gap_message:
raise ValueError(gap_message) raise ValueError(gap_message)
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
raise OutputsWouldBeDiscarded(targets)
clear_designing(project_root) clear_designing(project_root)
discard_initial_snapshot(project_root) discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root) await purge_project_outputs(connection, str(project_id), project_root)
@@ -86,6 +86,11 @@ from B03_FileInput.B03_FileInput_Schema_Temp import (
) )
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
from common_util.common_util_auth import verify_session from common_util.common_util_auth import verify_session
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
describe_existing_outputs,
)
from common_util.common_util_project_reset import purge_project_outputs from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import ( from common_util.common_util_storage import (
resolve_stored_project_path, resolve_stored_project_path,
@@ -359,6 +364,7 @@ async def upload_batch_files(
async def attach_temp_batch( async def attach_temp_batch(
project_id: UUID, project_id: UUID,
batch_id: str, batch_id: str,
confirm_replace: bool = False,
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
) -> TempBatchAttachResponse | JSONResponse: ) -> TempBatchAttachResponse | JSONResponse:
"""보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다. """보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다.
@@ -403,6 +409,11 @@ async def attach_temp_batch(
# 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시 # 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시
# 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. # 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
# ⚠ 되돌릴 수 없다 — 지울 것이 있으면 먼저 묻는다(2026-09-08).
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
return _confirm_replace_response(OutputsWouldBeDiscarded(targets))
async with pool.acquire() as connection: async with pool.acquire() as connection:
await connection.begin() await connection.begin()
try: try:
+6
View File
@@ -62,6 +62,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다. # 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]). # 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128) fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class ChunkSessionCreateResponse(BaseModel): class ChunkSessionCreateResponse(BaseModel):
@@ -103,6 +106,9 @@ class UploadFinalizeRequest(BaseModel):
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이 # 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택). # 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128) fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class UploadStatusResponse(BaseModel): class UploadStatusResponse(BaseModel):
+46 -14
View File
@@ -15,6 +15,7 @@ import {
checkWF1AnalysisStatus, checkWF1AnalysisStatus,
createUploadSession, createUploadSession,
finalizeUploadSession, finalizeUploadSession,
ReplaceOutputsConfirmRequired,
uploadFileChunk, uploadFileChunk,
type UploadedFileResult, type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch"; } from "./B03_FileInput_Api_Fetch";
@@ -77,6 +78,31 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
* 1 . . * 1 . .
* `onProgress` config . * `onProgress` config .
*/ */
/**
* .
* (2026-09-08 ).
* .
*/
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
try {
return await run(false);
} catch (error) {
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
const detail = error.targets.length
? `
: ${error.targets.join(" · ")}`
: "";
const agreed = window.confirm(
`${error.message}${detail}
. ?`,
);
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
return await run(true);
}
}
export async function uploadOneFile( export async function uploadOneFile(
projectId: string, projectId: string,
state: FileSlotState, state: FileSlotState,
@@ -97,13 +123,16 @@ export async function uploadOneFile(
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file); const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId; let session = state.uploadSessionId;
if (!session) { if (!session) {
const created = await createUploadSession( const created = await askReplaceOutputs((confirmReplace) =>
projectId, createUploadSession(
file, projectId,
chunkSizeBytes, file,
fingerprint, chunkSizeBytes,
completeUpload, fingerprint,
lasFree, completeUpload,
lasFree,
confirmReplace,
),
); );
if (created.already_uploaded) { if (created.already_uploaded) {
state.progressBytes = file.size; state.progressBytes = file.size;
@@ -155,13 +184,16 @@ export async function uploadOneFile(
} }
} }
const response = await finalizeUploadSession( const response = await askReplaceOutputs((confirmReplace) =>
projectId, finalizeUploadSession(
session, projectId,
totalChunks, session,
completeUpload, totalChunks,
fingerprint, completeUpload,
lasFree, fingerprint,
lasFree,
confirmReplace,
),
); );
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, { saveB03UploadedFile(projectId, {
@@ -30,6 +30,7 @@
} }
.b05-drainage.is-collapsed .b05-drainage__header, .b05-drainage.is-collapsed .b05-drainage__header,
.b05-drainage.is-collapsed .b05-drainage__summary,
.b05-drainage.is-collapsed .b05-drainage__viewport { .b05-drainage.is-collapsed .b05-drainage__viewport {
display: none; display: none;
} }
+9
View File
@@ -95,6 +95,15 @@
display: none; display: none;
} }
/* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 라벨이 줄어 2px 새어 나왔다.
폭에서만 라벨을 윗줄로 접는다. 넓은 배치는 손대지 않는다(2026-09-08 실측).
제한 없이 `flex-wrap` 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 보고 되돌렸다. */
@media (max-width: 560px) {
.b06-profile__field-row > .ui-field {
flex-wrap: wrap;
}
}
.b06-profile__field-row > .ui-btn { .b06-profile__field-row > .ui-btn {
flex: 0 0 auto; flex: 0 0 auto;
} }
+16 -2
View File
@@ -160,7 +160,12 @@ function injectStyles(): void {
.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } .b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); }
.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } .b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); }
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } /* min-width: 0 ** **(2026-09-08 :
620 1,190px ). flex auto
( nowrap) . 0 .b09-sheet
overflow: auto ** .**
릿 . */
.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; min-width: 0; }
.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } .b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; }
.b09-tab { .b09-tab {
font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer;
@@ -169,7 +174,12 @@ function injectStyles(): void {
.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } .b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); }
.b09-tab:disabled { cursor: not-allowed; opacity: .55; } .b09-tab:disabled { cursor: not-allowed; opacity: .55; }
.b09-sheet { overflow: auto; min-height: 0; flex: 1; } .b09-sheet { overflow: auto; min-height: 0; min-width: 0; flex: 1; }
/* ** (div) (table) **
(· ). overflow **
**(2026-09-08 실측: 620 1,190px).
. (thead·tbody) . */
table.b09-sheet { display: block; overflow-x: auto; max-width: 100%; }
.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } .b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); }
.b09-sheet th, .b09-sheet td { .b09-sheet th, .b09-sheet td {
border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right;
@@ -775,6 +785,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
const body = document.createElement("div"); const body = document.createElement("div");
body.style.flex = "1"; body.style.flex = "1";
body.style.minHeight = "0"; body.style.minHeight = "0";
// ⚠ 세로와 마찬가지로 **가로도 0 으로 끊어야** 한다(2026-09-08 실측). 안 끊으면 이 칸이
// 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물어 창 620 에서 1,190px 밀려 나갔다.
// 0 이면 아래 .b09-sheet 의 overflow:auto 가 살아나 **표만 제 안에서 가로로 넘어간다.**
body.style.minWidth = "0";
body.style.display = "flex"; body.style.display = "flex";
body.style.flexDirection = "column"; body.style.flexDirection = "column";