feat(설정): 334-2 ☰ 시스템 설정 4 페이지 바로가기 — 🏠 대시보드 + 카드 · 지금 칸 강조 · M02 → M02 이동은 떠나기 물음 먼저

- 새 A00_Common/m_settings_nav: settingLinks = 대시보드 카드 역할 규칙 한 벌 · mountSettingsDrawer = ☰ 진행 단계 자리
- B01 시스템 설정 카드가 settingLinks 로만 그림(화면 그대로)
- M01 · M02 page 한 줄씩(M02 = 진입 open · company 이어 실음)
- test_b01_settings_cards 가 옮긴 함수 소스를 같이 실음(기대값 그대로) · test_225 뒷부분

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-15 15:53:07 +09:00
co-authored by Claude Opus 5.5
parent c583dec29b
commit 26fcebeb46
6 changed files with 228 additions and 34 deletions
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* m_settings_nav.ts
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
* ========================================================================== */
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "./app_shell";
import { currentRoute, navigateTo, takeLeaveGuard } from "./router";
interface SettingUser {
role?: string;
is_master?: boolean;
company_id?: number | null;
}
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */
export interface SettingLink {
key: string;
title: string;
desc: string;
route: RoutePath;
query?: Record<string, string>;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
key: open,
title,
desc,
route: ROUTES.M02_MASTER_TEMPLATE,
query: { open, root, ...company },
});
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
return [
{
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: L("B01_Dashboard_PermSourceRead"),
route: ROUTES.M01_MASTER_DATA,
},
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
];
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
export function mountSettingsDrawer(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): void {
const links = settingLinks(user, companyId);
const bar = createStepBar(
links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey),
{
orientation: "vertical",
icons: links.map((link) => ICONS[link.key] ?? ""),
routes: links.map((link) => link.route),
homeButton: true,
onStepClick: (index) => void go(links[index]),
},
);
setDrawerSteps(bar);
}
async function go(link: SettingLink): Promise<void> {
const guard = link.route === currentRoute() ? takeLeaveGuard() : null;
if (guard && !(await guard())) return;
navigateTo(link.route, link.query);
}
+7 -31
View File
@@ -11,6 +11,7 @@ import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "../A00_Common/app_shell";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { currentRoute, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
@@ -164,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
return card;
}
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement =>
settingCard(title, desc, () =>
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
);
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
wrap.append(
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
);
return wrap;
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc));
wrap.append(
...settingLinks(user, companyId).map((link) =>
settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)),
),
);
return wrap;
}
+2
View File
@@ -19,6 +19,7 @@ import {
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { ROUTES } from "@config/config_frontend";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch";
@@ -54,6 +55,7 @@ export async function renderM01MasterData(root: HTMLElement): Promise<void> {
}
root.innerHTML = "";
root.append(buildPage());
mountSettingsDrawer(user, "m01", null); // ☰ 시스템 설정 바로가기(334)
}
function buildPage(): HTMLElement {
@@ -13,6 +13,7 @@ import { ROUTES } from "@config/config_frontend";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import { navigateTo, type MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
@@ -238,6 +239,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
mountSettingsDrawer(user ?? {}, entry.open, entry.company); // ☰ 시스템 설정 바로가기(334)
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
+114 -1
View File
@@ -1,8 +1,10 @@
"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2).
① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐
settingLinks 역할 셋 · 설정 ☰ 지금 칸(진입 인자)
② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) ·
「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52)
「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) · M01 ☰ 다섯 · 강조 · 「도면 양식」 → open=도면 ·
M02 → M02 이동은 떠나기 물음 먼저(가드 자리에 물음 창을 심어 확인 · 취소 = 머묾)
"""
from __future__ import annotations
@@ -19,6 +21,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄)
LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts"
NAV = ROOT / "A00_Common" / "m_settings_nav.ts"
STEPS = ["파일입력", "전처리", "종단설계"]
WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"]
@@ -81,6 +84,58 @@ def test_전처리_잠금은_길_기준():
assert _step_bar("USER", titles) == [False, False, False]
def _settings(user: dict, active: str, company) -> dict:
"""settingLinks 목록 + ☰ 에 맡긴 단계 막대(이름 · 지금 칸 · 아이콘 · 🏠)"""
src = NAV.read_text(encoding="utf-8")
icons = re.search(r"^const ICONS.*$", src, re.M).group(0)
script = (
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
" M02_MASTER_TEMPLATE: 'm02-master-template' };\n"
"const L = (k: string) => k;\nlet bar: any = null;\n"
"const createStepBar = (titles: string[], active: number, o: any) =>"
" ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n"
"const setDrawerSteps = (b: any) => { bar = b; };\nconst go = () => {};\n"
+ icons
+ "\n"
+ _fn(src, "settingLinks")
+ "\n"
+ _fn(src, "mountSettingsDrawer")
+ "\nconst [user, active, company] = JSON.parse(process.argv[1]);"
"\nmountSettingsDrawer(user, active, company);"
"\nconsole.log(JSON.stringify({ links: settingLinks(user, company).map((l) =>"
" [l.key, l.route, l.query ?? null]), bar }));"
)
return _node(script, json.dumps([user, active, company]))
M02 = "m02-master-template"
def test_설정_바로가기_역할_셋_지금_칸():
admin = _settings({"role": "SYSTEM_ADMIN", "company_id": 1}, "옵션", "3")
assert admin["links"] == [
["m01", "m01-master-data", None],
["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}],
["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}],
["도면", M02, {"open": "도면", "root": "master", "company": "3"}],
]
bar = admin["bar"]
assert bar["active"] == 2 and bar["home"] is True
assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"]
assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0
# 일위대가조합 = 강조 없음
assert _settings({"role": "SYSTEM_ADMIN"}, "일위대가조합", None)["bar"]["active"] == -1
# 그 밖 역할 = 🏠 + 구조물 조합 하나(회사 인자 안 실음)
company = _settings({"role": "ADMIN", "company_id": 1}, "구조물조합", "3")
assert company["links"] == [["구조물조합", M02, {"open": "구조물조합", "root": "personal"}]]
assert company["bar"]["active"] == 0
assert _settings({"role": "USER", "is_master": True}, "구조물조합", None)["links"][0][2] == {
"open": "구조물조합",
"root": "company",
}
assert _settings({"role": "USER"}, "구조물조합", None)["links"][0][2]["root"] == "personal"
# ── 실제 브라우저 ─────────────────────────────────────────────
@@ -157,3 +212,61 @@ def test_대시보드_구역_바로가기(who, count):
pass
assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top)
browser.close()
ACTIVE = "document.querySelector('.app-drawer__steps .is-active .ui-workflow-layout__step-label')?.textContent"
HASH = "decodeURIComponent(location.hash)"
def _drawer_click(page, label: str):
_open_drawer(page)
page.click(f".app-drawer__steps .ui-workflow-layout__step:has-text('{label}')")
@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐")
def test_설정_4_페이지_바로가기():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = _page(browser, "시스템 관리자", "m01-master-data")
page.wait_for_selector(".m01-master", timeout=60000)
_open_drawer(page)
assert page.evaluate(DRAWER_LABELS) == [
"대시보드",
"마스터 데이터",
"구조물 조합",
"구조물 옵션",
"도면 양식",
]
assert page.evaluate(ACTIVE) == "마스터 데이터"
page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('도면 양식')")
page.wait_for_function("decodeURIComponent(location.hash).includes('open=도면')")
assert M02 in page.evaluate(HASH)
page.wait_for_selector(".m02-master", timeout=60000)
page.wait_for_load_state("networkidle")
_open_drawer(page)
assert page.evaluate(ACTIVE) == "도면 양식"
page.click(".app-burger") # 닫음
# M02 → M02 — 저장 안 한 고침 대신 가드 자리에 앱의 물음 창을 심음
page.evaluate(
"""async () => {
const url = (p) => performance.getEntriesByType('resource')
.map((e) => e.name).find((n) => n.includes(p));
const router = await import(url('/router.ts'));
const ui = await import(url('/ui_template_elements.ts'));
router.setLeaveGuard(() => ui.showConfirmDialog('저장 안 한 고침'));
}"""
)
before = page.evaluate(HASH)
_drawer_click(page, "구조물 옵션")
page.wait_for_selector(".ui-confirm")
page.click(".ui-confirm__actions button >> nth=0") # 취소 = 머묾
page.wait_for_timeout(300)
assert page.evaluate(HASH) == before
_drawer_click(page, "구조물 옵션")
page.wait_for_selector(".ui-confirm")
page.click(".ui-confirm__actions button >> nth=1") # 버림 = 이동
page.wait_for_function("decodeURIComponent(location.hash).includes('open=옵션')")
browser.close()
+5 -2
View File
@@ -17,6 +17,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts"
NAV = ROOT / "A00_Common" / "m_settings_nav.ts" # 카드 규칙 한 벌(334)
def _text(path: Path) -> str:
@@ -24,9 +25,9 @@ def _text(path: Path) -> str:
def _fn(src: str, name: str) -> str:
found = re.search(rf"^function {name}\([\s\S]*?^}}", src, re.M)
found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M)
assert found, f"{name} 를 못 찾음"
return found.group(0)
return found.group(0).removeprefix("export ")
def _cards(user: dict, company_id: int | None = None) -> list:
@@ -46,6 +47,8 @@ def _cards(user: dict, company_id: int | None = None) -> list:
"const document = { createElement: () => mk() };\n"
+ _fn(src, "settingCard")
+ "\n"
+ _fn(_text(NAV), "settingLinks")
+ "\n"
+ _fn(src, "buildSystemSettingsPanel")
+ "\nconst user = JSON.parse(process.argv[1]);"
+ "\nconst panel = buildSystemSettingsPanel(user, JSON.parse(process.argv[2]));"