Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -51,9 +51,33 @@ export interface UploadStatusResponse {
|
||||
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> {
|
||||
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.status === 409 && payload.confirm === "replace_outputs") {
|
||||
throw new ReplaceOutputsConfirmRequired(
|
||||
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
|
||||
payload.targets ?? [],
|
||||
);
|
||||
}
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
@@ -62,9 +86,11 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
|
||||
export async function uploadProjectFiles(
|
||||
projectId: string,
|
||||
files: readonly File[],
|
||||
confirmReplace = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("files", file, file.name);
|
||||
if (confirmReplace) formData.append("confirm_replace", "true");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
@@ -88,6 +114,7 @@ export async function createUploadSession(
|
||||
fingerprint?: string | null,
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
@@ -100,6 +127,7 @@ export async function createUploadSession(
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
@@ -131,6 +159,7 @@ export async function finalizeUploadSession(
|
||||
completeUpload: boolean,
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
confirmReplace = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
@@ -142,6 +171,7 @@ export async function finalizeUploadSession(
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
confirm_replace: confirmReplace,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
|
||||
@@ -59,6 +59,10 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
from B03_FileInput.B03_FileInput_Router_Helpers import (
|
||||
_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 (
|
||||
_already_uploaded as _already_uploaded,
|
||||
)
|
||||
@@ -132,6 +136,7 @@ async def upload_project_files(
|
||||
project_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
las_free: bool = Form(False),
|
||||
confirm_replace: bool = Form(False),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> FileUploadResponse | JSONResponse:
|
||||
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
|
||||
@@ -258,7 +263,7 @@ async def upload_project_files(
|
||||
)
|
||||
)
|
||||
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()
|
||||
except Exception:
|
||||
@@ -282,6 +287,10 @@ async def upload_project_files(
|
||||
task_name=f"b04-preprocess-auto-{project_id}",
|
||||
)
|
||||
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:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
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_Helpers import (
|
||||
_ANALYSIS_RUNNING_MESSAGE,
|
||||
OutputsWouldBeDiscarded,
|
||||
_already_uploaded,
|
||||
_complete_file_input_if_ready,
|
||||
_confirm_replace_response,
|
||||
_schedule_background_task,
|
||||
_total_chunks,
|
||||
_write_stage_metadata,
|
||||
@@ -110,6 +112,7 @@ async def create_project_upload_session(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -144,6 +147,8 @@ async def create_project_upload_session(
|
||||
chunk_size_bytes=chunk_size_bytes,
|
||||
total_chunks=total_chunks,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -202,6 +207,8 @@ async def upload_project_chunk(
|
||||
total_chunks=int(upload_session["total_chunks"]),
|
||||
chunk_hash=chunk_hash,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -300,6 +307,7 @@ async def finalize_project_upload(
|
||||
connection,
|
||||
project_id,
|
||||
payload.las_free,
|
||||
payload.confirm_replace,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
@@ -329,6 +337,8 @@ async def finalize_project_upload(
|
||||
task_name=f"b04-preprocess-auto-{project_id}",
|
||||
)
|
||||
return FileUploadResponse(project_id=str(project_id), files=[result])
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
@@ -373,6 +383,8 @@ async def get_project_upload_status(
|
||||
completed_chunks=len(completed_indexes),
|
||||
completed_chunk_indexes=completed_indexes,
|
||||
)
|
||||
except OutputsWouldBeDiscarded as exc:
|
||||
return _confirm_replace_response(exc)
|
||||
except LookupError as exc:
|
||||
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
|
||||
except (OSError, ValueError) as exc:
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Email import (
|
||||
send_file_upload_complete_email,
|
||||
@@ -40,6 +41,61 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_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"})
|
||||
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
|
||||
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
|
||||
@@ -149,7 +205,13 @@ async def _complete_file_input_if_ready(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: UUID,
|
||||
las_free: bool = False,
|
||||
confirm_replace: bool = True,
|
||||
) -> int:
|
||||
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
|
||||
|
||||
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
|
||||
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
|
||||
"""
|
||||
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
|
||||
connection, project_id
|
||||
)
|
||||
@@ -172,6 +234,11 @@ async def _complete_file_input_if_ready(
|
||||
gap_message = merge_gap_error(terrain_paths)
|
||||
if 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)
|
||||
discard_initial_snapshot(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 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_storage import (
|
||||
resolve_stored_project_path,
|
||||
@@ -359,6 +364,7 @@ async def upload_batch_files(
|
||||
async def attach_temp_batch(
|
||||
project_id: UUID,
|
||||
batch_id: str,
|
||||
confirm_replace: bool = False,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> TempBatchAttachResponse | JSONResponse:
|
||||
"""보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다.
|
||||
@@ -403,6 +409,11 @@ async def attach_temp_batch(
|
||||
|
||||
# 자료가 갈리므로 옛 계산 결과(파일 + 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:
|
||||
await connection.begin()
|
||||
try:
|
||||
|
||||
@@ -62,6 +62,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
|
||||
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
|
||||
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
|
||||
|
||||
class ChunkSessionCreateResponse(BaseModel):
|
||||
@@ -103,6 +106,9 @@ class UploadFinalizeRequest(BaseModel):
|
||||
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
|
||||
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
|
||||
fingerprint: str | None = Field(default=None, max_length=128)
|
||||
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
|
||||
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
|
||||
confirm_replace: bool = False
|
||||
|
||||
|
||||
class UploadStatusResponse(BaseModel):
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
checkWF1AnalysisStatus,
|
||||
createUploadSession,
|
||||
finalizeUploadSession,
|
||||
ReplaceOutputsConfirmRequired,
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
@@ -77,6 +78,31 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
|
||||
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
|
||||
* 진행 상황은 `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(
|
||||
projectId: string,
|
||||
state: FileSlotState,
|
||||
@@ -97,13 +123,16 @@ export async function uploadOneFile(
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
projectId,
|
||||
file,
|
||||
chunkSizeBytes,
|
||||
fingerprint,
|
||||
completeUpload,
|
||||
lasFree,
|
||||
const created = await askReplaceOutputs((confirmReplace) =>
|
||||
createUploadSession(
|
||||
projectId,
|
||||
file,
|
||||
chunkSizeBytes,
|
||||
fingerprint,
|
||||
completeUpload,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
),
|
||||
);
|
||||
if (created.already_uploaded) {
|
||||
state.progressBytes = file.size;
|
||||
@@ -155,13 +184,16 @@ export async function uploadOneFile(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await finalizeUploadSession(
|
||||
projectId,
|
||||
session,
|
||||
totalChunks,
|
||||
completeUpload,
|
||||
fingerprint,
|
||||
lasFree,
|
||||
const response = await askReplaceOutputs((confirmReplace) =>
|
||||
finalizeUploadSession(
|
||||
projectId,
|
||||
session,
|
||||
totalChunks,
|
||||
completeUpload,
|
||||
fingerprint,
|
||||
lasFree,
|
||||
confirmReplace,
|
||||
),
|
||||
);
|
||||
localStorage.removeItem(storageKey);
|
||||
saveB03UploadedFile(projectId, {
|
||||
|
||||
@@ -160,7 +160,12 @@ function injectStyles(): void {
|
||||
.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-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-tab {
|
||||
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: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 th, .b09-sheet td {
|
||||
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");
|
||||
body.style.flex = "1";
|
||||
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.flexDirection = "column";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user