refactor(B02·B03): 페이지 자체 변종을 부품 상자 부품으로 — 파일 카드 · 띠 알림 · 진행 막대 · 켜기 · 패널 머리 · 단추 (PLAN 553-1)

- B03 파일 카드 = 카드 부품 · [선택] · [×] = 단추 · 아이콘 단추 · 진행 막대 · 오류 띠 = 부품
- B03 현황 · 이어올리기 · 예정노선 바뀜 = 띠 알림 · LAS 없이 설계 = 켜기 · 노선 · 지형 제목 = 패널 머리
- B03 · B02 죽은 CSS 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:53:32 +09:00
co-authored by Claude Sonnet 5.5
parent 9729892a3d
commit a826804277
11 changed files with 244 additions and 368 deletions
+68 -57
View File
@@ -5,7 +5,12 @@ import {
UPLOAD_MAX_FILES,
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag } from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, createIconButton, createTag } from "@ui/ui_template_elements";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createProgressBar, type ProgressBarHandle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
@@ -63,6 +68,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다.
const tempPicker = createTempPicker(() => updateUploadButton());
const cardMap = new Map<FileSlot, HTMLElement>();
const progressMap = new Map<FileSlot, ProgressBarHandle>();
let uploadButton: HTMLButtonElement;
let resumeBanner: HTMLDivElement;
let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
@@ -75,9 +81,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1"
: false;
const subtitle = document.createElement("p");
subtitle.className = "b03-file__subtitle";
subtitle.textContent = L("B03_File_Subtitle");
const subtitle = createText(L("B03_File_Subtitle"), "muted", 14);
subtitle.classList.add("b03-file__subtitle");
const fileInput = document.createElement("input");
fileInput.type = "file";
@@ -94,7 +99,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
dropzone.append(dropzoneLabel, fileInput);
const pageError = document.createElement("p");
pageError.className = "b03-file__error";
pageError.className = "ui-text ui-text--error b03-file__error";
pageError.setAttribute("role", "alert");
// 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다.
@@ -200,11 +205,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progress = progressMap.get(slot);
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const error = card.querySelector<HTMLElement>(".b03-file__error-message .ui-banner__text");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
@@ -217,7 +222,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
? `${state.serverUploaded.sizeMb.toFixed(2)} MB`
: "";
}
if (progress) progress.style.width = `${percent}%`;
progress?.set(percent / 100);
if (progressBytes) {
progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes(
state.progressBytes,
@@ -413,26 +418,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const lock = overview.analysis_lock;
if (lock?.running) {
const who = lock.name ?? lock.email ?? null;
const busy = document.createElement("p");
busy.className = "b03-file__overview-lock";
busy.textContent = who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.";
notes.push(busy);
notes.push(
createBanner({
tone: "warning",
text: who
? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.`
: "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.",
}),
);
}
if (overview.required_complete && overview.analysis_complete) {
const complete = document.createElement("p");
complete.className = "b03-file__overview-complete";
complete.textContent = L("B03_File_Overview_Complete");
notes.push(complete);
notes.push(createBanner({ tone: "success", text: L("B03_File_Overview_Complete") }));
}
for (const session of overview.pending_sessions) {
const pending = document.createElement("p");
pending.className = "b03-file__overview-pending";
pending.textContent =
`${session.original_filename} — ${session.progress_percent}% ` +
L("B03_File_Overview_Pending");
notes.push(pending);
notes.push(
createBanner({
tone: "info",
text:
`${session.original_filename} — ${session.progress_percent}% ` +
L("B03_File_Overview_Pending"),
}),
);
}
if (notes.length) {
overviewBanner.append(...notes);
@@ -453,18 +459,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
select.textContent = L("B03_File_Card_Select");
const select = createButton({ label: L("B03_File_Card_Select"), variant: "ghost", size: 24 });
select.classList.add("b03-file__card-select");
card.querySelector('[data-slot="select"]')!.replaceWith(select);
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
remove.addEventListener("click", () => removeFile(state.slot));
const remove = createIconButton({
glyph: "×",
title: L("B03_File_Card_Remove"),
variant: "outline",
size: 24,
onClick: () => removeFile(state.slot),
});
remove.classList.add("b03-file__card-remove");
card.querySelector('[data-slot="remove"]')!.replaceWith(remove);
const progress = createProgressBar({ thick: 6 });
progress.root.classList.add("b03-file__progress");
card.querySelector('[data-slot="progress"]')!.replaceWith(progress.root);
progressMap.set(state.slot, progress);
const errorBanner = createBanner({ tone: "danger", text: "" });
errorBanner.classList.add("b03-file__error-message");
card.querySelector('[data-slot="error"]')!.replaceWith(errorBanner);
cardMap.set(state.slot, card);
return card;
}
@@ -478,18 +496,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const group = document.createElement("section");
group.className = "b03-file__group";
if (modifier) group.classList.add(modifier);
if (title) {
const groupTitle = document.createElement("h3");
groupTitle.className = "b03-file__group-title";
groupTitle.textContent = title;
group.append(groupTitle);
}
if (hint) {
const groupHint = document.createElement("p");
groupHint.className = "b03-file__group-hint";
groupHint.textContent = hint;
group.append(groupHint);
}
if (title) group.append(createPanelHead({ title, sub: hint }).root);
const content = document.createElement("div");
content.className = "b03-file__group-content";
for (const slot of groupSlots) {
@@ -560,7 +567,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
pickRow.append(dropzone, tempPicker.root, uploadButton);
const uploadControlPanel = document.createElement("div");
uploadControlPanel.className = "b03-file__control-panel";
uploadControlPanel.dataset.pad = "16";
uploadControlPanel.className = "ui-card b03-file__control-panel";
uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError);
// 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터).
@@ -581,16 +589,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
);
// LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다.
const lasFreeRow = document.createElement("label");
lasFreeRow.className = "b03-file__lasfree";
const lasFreeToggle = createToggle({
label: L("B03_File_LasFree_Toggle"),
checked: lasFreeDesign,
size: 36,
onChange: onLasFreeChange,
});
const lasFreeRow = lasFreeToggle.root;
lasFreeRow.classList.add("b03-file__lasfree");
lasFreeRow.title = L("B03_File_LasFree_Hint");
const lasFreeCheck = document.createElement("input");
lasFreeCheck.type = "checkbox";
const lasFreeText = document.createElement("span");
lasFreeText.textContent = L("B03_File_LasFree_Toggle");
lasFreeRow.append(lasFreeCheck, lasFreeText);
function applyLasFreeState(): void {
lasFreeCheck.checked = lasFreeDesign;
lasFreeToggle.set(lasFreeDesign);
terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign);
for (const slot of TERRAIN_SLOTS) {
const card = cardMap.get(slot);
@@ -608,8 +617,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot);
}
}
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
function onLasFreeChange(checked: boolean): void {
lasFreeDesign = checked;
if (activeProjectId) {
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
}
@@ -619,19 +628,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고
// 토글을 나중에 켠 순서에서 재현).
updateUploadButton();
});
}
// LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지
// 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다.
uploadControlPanel.insertBefore(lasFreeRow, resumeBanner);
const routePanel = document.createElement("div");
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
routePanel.dataset.pad = "16";
routePanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel";
routePanel.append(routeGroup);
mountPlanImport(routePanel, uploadControlPanel, () => void applyUploadOverview());
const terrainPanel = document.createElement("div");
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.dataset.pad = "16";
terrainPanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.append(terrainGroup);
const cardsContainer = document.createElement("div");