refactor(E01·E02): 빠진 횡단 띠 · 지반 카드 · 입력 · 고르기 · 안내 글 · 상태 글을 부품으로 (PLAN 553-4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:01:22 +09:00
co-authored by Claude Sonnet 5.5
parent f54eed25bc
commit 91e3dc4141
7 changed files with 89 additions and 106 deletions
@@ -1,21 +0,0 @@
/* 빠진 횡단 띠 — E02 · E03 화면 위(E01_StabRegister_UI_Missing.ts) */
.e-missing {
margin: 0 0 8px;
padding: 6px 10px;
border: 1px solid var(--color-warning);
border-left-width: 4px;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
font-size: 13px;
}
.e-missing > summary {
cursor: pointer;
font-weight: 600;
}
.e-missing > p {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
@@ -1,10 +1,11 @@
/* =============================================================================
* E01_StabRegister_UI_Missing.ts
* 빠진 횡단 띠(요구 351 · PLAN 532-5) — E02 · E03 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」.
* 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 누르면 펼침.
* 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 「자세히」로 펼침.
* 모양 = 띠 알림 부품(경고).
* ========================================================================== */
import "./E01_StabRegister_UI_Missing.css";
import { createBanner } from "@ui/ui_template_banner";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
@@ -12,13 +13,13 @@ import { t } from "@ui/ui_template_locale";
export function buildMissingBanner(missing: number[] | undefined): HTMLElement {
const list = missing ?? [];
if (!list.length) return el("div");
const box = el("details", {
className: "e-missing",
attrs: { "data-role": "missing-stations", "data-count": String(list.length) },
const box = createBanner({
tone: "warning",
text: t("E_Missing_Head").replace("{n}", String(list.length)),
details: el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }),
});
box.append(
el("summary", { text: t("E_Missing_Head").replace("{n}", String(list.length)) }),
el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }),
);
box.classList.add("e-missing");
box.dataset.role = "missing-stations";
box.dataset.count = String(list.length);
return box;
}
+20 -14
View File
@@ -18,10 +18,11 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { buildCards, type E02View } from "./E02_StabGround_UI_Tables";
import { buildCards, hook, type E02View } from "./E02_StabGround_UI_Tables";
import {
checkGround,
type CheckError,
@@ -74,7 +75,7 @@ async function request(path: string, init?: RequestInit): Promise<GroundResponse
export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e02-page" });
const status = el("p", { className: "e02-status" });
const status = el("div", { className: "e02-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "filled",
@@ -89,10 +90,9 @@ export function renderE02StabGround(root: HTMLElement): void {
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions);
panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") }));
if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty"));
let view: E02View | null = null;
let refreshStations = (): void => {};
@@ -104,22 +104,28 @@ export function renderE02StabGround(root: HTMLElement): void {
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
if (dirty) lines.push(t("E02_Unsaved"));
if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`);
status.textContent = lines.join("\n");
status.replaceChildren(
...(lines.length
? [createText(lines.join("\n"), errors.length > 0 ? "error" : "warning")]
: []),
);
status.classList.toggle("is-error", errors.length > 0);
};
/** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */
/** 틀린 칸 표시 — 칸마다 is-error(부품 오류 상태) + data-error(마우스 글) */
const mark = (): CheckError[] => {
if (!view) return [];
const errors = checkGround(view.ground, view.defaults.limits);
for (const node of main.querySelectorAll<HTMLElement>(".is-invalid")) {
node.classList.remove("is-invalid");
for (const node of main.querySelectorAll<HTMLElement>("[data-error]")) {
node.classList.remove("is-error");
node.removeAttribute("aria-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[data-path="${error.path}"]`);
if (!node) continue;
node.classList.add("is-invalid");
node.classList.add("is-error");
node.setAttribute("aria-invalid", "true");
node.dataset.error = errorText(error, view.defaults);
}
paintStatus(errors);
@@ -242,10 +248,10 @@ export function renderE02StabGround(root: HTMLElement): void {
adopt(body, true);
} catch (error) {
main.replaceChildren(
el("p", {
className: "e02-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
hook(
createText(error instanceof Error ? error.message : t("Common_Status_Error")),
"e02-empty",
),
);
}
})();
+7 -35
View File
@@ -10,6 +10,10 @@
box-sizing: border-box;
}
.e02-page > .ui-banner {
margin-bottom: var(--spacing-8);
}
.e02-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(560px, 100%), 1fr));
@@ -17,33 +21,19 @@
align-items: start;
}
/* 카드 모양은 카드 부품(머리 카드) — 여기는 자리만 */
.e02-card {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-12) var(--spacing-16);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.e02-card__head {
display: flex;
align-items: center;
justify-content: space-between;
.e02-card .ui-card__body {
gap: var(--spacing-8);
}
.e02-card__title,
.e02-card__sub {
margin: 0;
margin: var(--spacing-8) 0 0;
color: var(--color-text);
font-size: var(--text-body);
}
.e02-card__sub {
font-size: var(--text-body-sm);
margin-top: var(--spacing-8);
}
.e02-card__actions {
@@ -103,11 +93,6 @@
min-width: 96px;
}
.e02-input.is-invalid {
border-color: var(--color-danger);
box-shadow: 0 0 0 1px var(--color-danger);
}
.e02-rowhead {
white-space: nowrap;
color: var(--color-text-secondary);
@@ -135,22 +120,9 @@
color: var(--color-text-secondary);
}
.e02-hint,
.e02-empty {
margin: 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.e02-status {
margin: var(--spacing-8) 0 0;
white-space: pre-line;
font-size: var(--text-body-sm);
color: var(--color-warning);
}
.e02-status.is-error {
color: var(--color-danger);
}
.e02-table .ui-btn {
+27 -24
View File
@@ -6,8 +6,16 @@
* 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild().
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_card";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import {
@@ -32,6 +40,11 @@ export interface E02View {
}
const L = (key: string): string => t(key as LocaleKey);
/** 부품이 낸 요소에 촬영 장면 · 시험이 잡는 이름만 얹음(CSS 없음) */
export const hook = <T extends HTMLElement>(node: T, name: string): T => (
node.classList.add(name),
node
);
export const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = {
gamma_t: "E02_Col_GammaT",
@@ -48,10 +61,8 @@ export function numInput(
set: (v: number | null) => void,
meta: { src?: string; def?: number | null; nullable?: boolean } = {},
): HTMLInputElement {
const input = el("input", {
className: "ui-input ui-input--sm e02-input e02-input--num",
}) as HTMLInputElement;
input.type = "number";
const input = createInputField({ type: "number", compact: true }).input;
input.classList.add("e02-input", "e02-input--num");
input.step = "any";
input.value = value === null || value === undefined ? "" : String(value);
input.dataset.path = path;
@@ -75,10 +86,8 @@ export function selectInput(
set: (v: string) => void,
meta: { src?: string; def?: string } = {},
): HTMLSelectElement {
const select = el("select", {
className: "ui-select ui-select--sm e02-input",
}) as HTMLSelectElement;
for (const option of options) select.append(new Option(option.text, option.value));
const select = createSelectField({ options, compact: true }).select;
select.classList.add("e02-input");
select.value = value ?? "";
select.dataset.path = path;
if (meta.src) select.dataset.src = meta.src;
@@ -95,10 +104,9 @@ export function card(
title: LocaleKey,
...body: HTMLElement[]
): { section: HTMLElement; head: HTMLElement } {
const head = el("div", { className: "e02-card__head" });
head.append(el("h3", { className: "e02-card__title", text: t(title) }));
const section = el("section", { className: "e02-card ui-sidebar-section" });
section.append(head, ...body);
const head = el("div", { className: "e02-card__actions" });
const section = createCard({ title: t(title), actions: [head], body, pad: 16 });
section.classList.add("e02-card", "ui-sidebar-section");
return { section, head };
}
@@ -108,9 +116,7 @@ export function headButtons(
...buttons: HTMLButtonElement[]
): void {
if (!view.editable) return;
const row = el("div", { className: "e02-card__actions" });
row.append(...buttons);
head.append(row);
head.append(...buttons);
}
export function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement {
@@ -141,9 +147,8 @@ function materialsCard(view: E02View): HTMLElement {
Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id);
const rows = g.materials.map((m, i) => {
const base = `materials.${i}`;
const name = el("input", {
className: "ui-input ui-input--sm e02-input e02-input--name",
}) as HTMLInputElement;
const name = createInputField({ compact: true }).input;
name.classList.add("e02-input", "e02-input--name");
name.value = m.name;
name.dataset.path = `${base}.name`;
name.disabled = !view.editable;
@@ -293,15 +298,13 @@ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void }
],
rows,
)
: el("p", { className: "e02-empty", text: t("E02_Zone_Empty") });
: hook(createText(t("E02_Zone_Empty")), "e02-empty");
const stationsHost = el("div", { className: "e02-stations" });
const refresh = (): void => {
stationsHost.replaceChildren();
if (!g.stations.length)
return void stationsHost.append(
el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }),
);
return void stationsHost.append(hook(createText(t("E02_Stations_Empty")), "e02-empty"));
const names = new Map(g.materials.map((m) => [m.id, m.name]));
const interval = inferStationInterval(g.stations);
const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2));
@@ -332,7 +335,7 @@ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void }
const { section, head } = card(
"E02_Card_Zones",
el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }),
hook(createText(t("E02_Zone_Hint")), "e02-hint"),
zoneBody,
el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }),
stationsHost,
+5 -3
View File
@@ -6,12 +6,14 @@
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { WATER_KEYS, WATER_RULES, WET_MODES, type WetMode } from "./logic/E02_StabGround_Check";
import {
card,
clone,
headButtons,
hook,
numInput,
selectInput,
table,
@@ -40,7 +42,7 @@ function removeButton(view: E02View, onClick: () => void): HTMLButtonElement {
/** 사용자 수위선 구간 표 — 줄마다 시작 · 끝 · 점(offset · 깊이) · [점 추가] · [지움] */
function linesBody(view: E02View): HTMLElement {
const water = view.ground.groundwater;
if (!water.lines.length) return el("p", { className: "e02-empty", text: t("E02_Line_Empty") });
if (!water.lines.length) return hook(createText(t("E02_Line_Empty")), "e02-empty");
const rows = water.lines.map((line, i) => {
const base = `groundwater.lines.${i}`;
const points = el("div", { className: "e02-points" });
@@ -136,11 +138,11 @@ export function waterCard(view: E02View): HTMLElement {
),
);
if (water.wet_mode === "line")
body.push(el("p", { className: "e02-hint", text: t("E02_Line_Hint") }), linesBody(view));
body.push(hook(createText(t("E02_Line_Hint")), "e02-hint"), linesBody(view));
const { section, head } = card(
"E02_Card_Water",
el("p", { className: "e02-hint", text: t("E02_Water_Hint") }),
hook(createText(t("E02_Water_Hint")), "e02-hint"),
grid,
...body,
);
+20
View File
@@ -57,3 +57,23 @@ def test_d04_parts():
assert "input:checked" in ts # [확정] 이 고른 연도를 읽는 길
assert "border:" not in css.split(".d04-confirm__input")[1].split("}")[0]
assert ".d04-confirm__dirty" not in css
def test_e01_missing_is_banner_part():
ts = _src("E01_StabRegister/E01_StabRegister_UI_Missing.ts")
assert "createBanner" in ts and '"details"' not in ts
assert 'dataset.role = "missing-stations"' in ts # 시험 · 촬영이 잡음
assert not (ROOT / "E01_StabRegister/E01_StabRegister_UI_Missing.css").exists()
def test_e02_parts():
tables = _src("E02_StabGround/E02_StabGround_UI_Tables.ts")
page = _src("E02_StabGround/E02_StabGround_UI_Page.ts")
css = _src("E02_StabGround/E02_StabGround_UI_Style.css")
assert 'el("input"' not in tables and 'el("select"' not in tables
assert "createInputField(" in tables and "createSelectField(" in tables
assert "createCard({ title" in tables
assert "is-invalid" not in page + css and "is-error" in page
for dead in (".e02-card__head", ".e02-card__title", ".e02-hint", ".e02-empty"):
assert dead not in css, dead
_no_hex("E02_StabGround/E02_StabGround_UI_Style.css")