From e505f4014d686ba0765cfcee4a4efda55312feb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 18:05:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=83=88=20=ED=8B=80=20=E2=80=94=20=EC=85=B8=20?= =?UTF-8?q?=C2=B7=20=EC=99=BC=EC=AA=BD=20=EB=A9=94=EB=89=B4=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=EB=B6=80=20(546-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셸 = 위 머리 48(AISLO · 회사 이름 — 시스템 관리자 회사 전환 · 찾기 자리 · [?] · 알림 자리 · 사용자 메뉴) + 왼쪽 메뉴 210 + 챗봇 단추 - 등록부 B01_Dashboard_UI_Views.ts — { key, title, group, roles, icon, mount(host, ctx) } · 주소 ?view= · 같은 주소 안 이동 = 페이지만 갈아 끼움(MountedPage) - 역할 넷(일반 · 회사 관리자 · 산림청 · 시스템 관리자) · 권한 없는 묶음 · 항목 숨김 · 권한 없는 열쇠 = 홈 - 열쇠마다 옛 구역 부품 그대로 얹음(B01_Dashboard_UI_Legacy.ts) — 기능 빠짐 0 - 옛 ☰ 서랍 · 구역 접기 · 회사 알약 걷음 — 테마 · 언어 · 터치 · 설정 · 로그아웃은 사용자 메뉴로 - 아이콘 = Tabler(MIT) 선 아이콘 몇 개(ui_template_icons.ts · 라이센스 글 함께) - 시험 — test_b01_settings_cards 옛 한 페이지 단정(buildPage · 접힘 · 알약) 지움 · 셸 회사 고르기 단정으로 바꿈 · test_546_b01_shell 새로 Co-Authored-By: Claude Opus 5.5 --- B01_Dashboard/B01_Dashboard_UI_Legacy.ts | 286 ++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Nav.ts | 107 +++++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 374 +----------------- B01_Dashboard/B01_Dashboard_UI_Shell.ts | 276 +++++++++++++ .../B01_Dashboard_UI_Style_Shell.css | 353 +++++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Views.ts | 81 ++++ resources/tester/test_309_2_calc_sites.py | 2 +- resources/tester/test_546_b01_shell.py | 106 +++++ resources/tester/test_b01_settings_cards.py | 52 +-- ui_template/ui_template_icons.ts | 78 ++++ 10 files changed, 1320 insertions(+), 395 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_Legacy.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_Nav.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_Shell.ts create mode 100644 B01_Dashboard/B01_Dashboard_UI_Style_Shell.css create mode 100644 B01_Dashboard/B01_Dashboard_UI_Views.ts create mode 100644 resources/tester/test_546_b01_shell.py create mode 100644 ui_template/ui_template_icons.ts diff --git a/B01_Dashboard/B01_Dashboard_UI_Legacy.ts b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts new file mode 100644 index 000000000..d06f2bd88 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Legacy.ts @@ -0,0 +1,286 @@ +/* ============================================================================= + * B01_Dashboard_UI_Legacy.ts + * 옛 한 페이지 대시보드의 구역 부품을 세부 페이지에 그대로 얹음(546-1) — 기능 빠짐 0. + * 새 모양 페이지가 등록부(`B01_Dashboard_UI_Views.ts`) 줄을 갈아 끼우면 여기 그 구역은 지움. + * 자료 = 열 때마다 역할별로 다시 읽음(옛 화면이 주소 바뀔 때마다 다시 읽던 것과 같음). + * ========================================================================== */ + +import { navigateTo } from "../A00_Common/router"; +import { settingLinks } from "../A00_Common/m_settings_nav"; +import { createButton, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements"; +import { section } from "@ui/ui_template_general_blocks"; +import { + fetchAllCompanies, + fetchAllUsers, + fetchAuditLogs, + fetchCompanyJoinRequests, + fetchCompanyMembers, + fetchCompanyProjects, + fetchSystemResources, + fetchUserCompany, + fetchUserProjects, + type AuditLog, + type CompanyInfo, + type DashboardUser, + type JoinRequest, + type Member, + type ProjectItem, + type ResourceData, +} from "./B01_Dashboard_Api_Fetch"; +import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; +import { + assignedPlanTable, + fetchAssignedPlans, + type AssignedPlan, +} from "./B01_Dashboard_UI_AssignedPlans"; +import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites"; +import { L } from "./B01_Dashboard_UI_Common"; +import { + buildCompanyPanel, + companyTable, + joinRequestTable, + memberTable, +} from "./B01_Dashboard_UI_Company"; +import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals"; +import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; +import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; +import { projectTable } from "./B01_Dashboard_UI_Projects"; +import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; +import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; + +export interface DashboardState { + user: DashboardUser; + userProjects: ProjectItem[]; + companyProjects: ProjectItem[]; + allProjects: ProjectItem[]; + company: CompanyInfo | null; + members: Member[]; + joinRequests: JoinRequest[]; + allCompanies: CompanyInfo[]; + allUsers: DashboardUser[]; + allJoinRequests: JoinRequest[]; + auditLogs: AuditLog[]; + resources: ResourceData | null; + /** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */ + assignedPlans: AssignedPlan[]; + /** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */ + adminCompanyId: number | null; +} + +/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */ +export const ADMIN_COMPANY_KEY = "aislo.adminCompany"; + +/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */ +export function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null { + const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY)); + if (companies.some((c) => c.id === saved)) return saved; + return user.company_id ?? companies[0]?.id ?? null; +} + +async function loadRoleData(state: DashboardState): Promise { + if (state.user.role === "SYSTEM_ADMIN") { + // 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1) + state.allCompanies = await fetchAllCompanies().catch(() => []); + const companyId = pickAdminCompany(state.user, state.allCompanies); + state.adminCompanyId = companyId; + const results = await Promise.allSettled([ + fetchAllUsers(companyId), + fetchCompanyJoinRequests(companyId), + fetchAuditLogs(), + fetchSystemResources(7), + fetchCompanyProjects(companyId), + ]); + state.allUsers = results[0].status === "fulfilled" ? results[0].value : []; + state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : []; + state.auditLogs = results[2].status === "fulfilled" ? results[2].value : []; + state.resources = results[3].status === "fulfilled" ? results[3].value : null; + state.allProjects = results[4].status === "fulfilled" ? results[4].value : []; + + results.forEach((res, idx) => { + if (res.status === "rejected") { + console.error(`System Admin API failed at index ${idx}:`, res.reason); + } + }); + return; + } + const [projects, company, assigned] = await Promise.all([ + fetchUserProjects().catch(() => []), + fetchUserCompany().catch(() => null), + fetchAssignedPlans().catch(() => []), + ]); + state.userProjects = projects; + state.company = company; + state.assignedPlans = assigned; + if (state.user.role === "ADMIN") { + const [members, requests, companyProjects] = await Promise.all([ + fetchCompanyMembers().catch(() => []), + fetchCompanyJoinRequests().catch(() => []), + fetchCompanyProjects().catch(() => []), + ]); + state.members = members; + state.joinRequests = requests; + state.companyProjects = companyProjects; + } +} + +/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */ +function settingCard(title: string, desc: string, go: () => void): HTMLElement { + const card = document.createElement("button"); + card.type = "button"; + card.className = "b01-dashboard__setting-card"; + const head = document.createElement("strong"); + head.className = "b01-dashboard__setting-title"; + head.textContent = title; + const sub = document.createElement("span"); + sub.className = "b01-dashboard__setting-desc"; + sub.textContent = desc; + card.append(head, sub); + card.addEventListener("click", go); + return card; +} + +/** 설계 기본값 카드 — 카드 규칙은 `settingLinks` 한 벌(334) · 계산 자리 = 창(504-2) */ +function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-dashboard__setting-grid"; + wrap.append( + ...settingLinks(user, companyId).map((link) => + settingCard(link.title, link.desc, () => + link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query), + ), + ), + ); + return wrap; +} + +const addButton = (onClick: () => void): HTMLElement[] => [createButton({ label: "+", onClick })]; + +/** 열쇠별 옛 구역 — 역할 갈래는 옛 `buildPage` 그대로 */ +const SECTIONS: Record HTMLElement[]> = { + projects: (s) => { + const list = + s.user.role === "SYSTEM_ADMIN" + ? s.allProjects + : s.user.role === "ADMIN" + ? s.companyProjects + : s.userProjects; + return [ + section( + L("B01_Dashboard_Projects"), + projectTable(list, s.user), + true, + addButton(() => void openNewProjectPicker()), + ), + ]; + }, + assigned: (s) => [section(L("DE_Assigned_Title"), assignedPlanTable(s.assignedPlans), true)], + temp: () => [buildTempUploadSection()], + people: (s) => + s.user.role === "SYSTEM_ADMIN" + ? [ + section( + L("B01_Dashboard_Users"), + userTable(s.allUsers, s.user), + true, + addButton(() => openAddMemberModal(undefined, s.adminCompanyId)), + ), + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.allJoinRequests, true), true), + ] + : [ + section( + L("B01_Dashboard_Members"), + memberTable(s.members, s.user), + true, + addButton(() => openAddMemberModal()), + ), + section(L("B01_Dashboard_JoinRequests"), joinRequestTable(s.joinRequests, false), true), + ], + company: (s) => + s.user.role === "SYSTEM_ADMIN" + ? [ + section( + L("B01_Dashboard_Companies"), + companyTable( + s.allCompanies.filter((c) => c.id === s.adminCompanyId), + s.user, + ), + true, + ), + ] + : [section(L("B01_Dashboard_Company"), buildCompanyPanel(s), true)], + companyDefaults: (s) => [ + section( + L("B01_Dashboard_SystemSettings"), + buildSystemSettingsPanel(s.user, s.adminCompanyId), + true, + ), + ], + sysOverview: (s) => [ + section(L("B01_Dashboard_Resources"), buildResourcePanel(s.resources), true), + ], + sysCompanies: (s) => [ + section( + L("B01_Dashboard_Companies"), + companyTable(s.allCompanies, s.user), + true, + addButton(() => openCreateCompanyModal()), + ), + ], + sysUsers: (s) => [ + section( + L("B01_Dashboard_Users"), + userTable(s.allUsers, s.user), + true, + addButton(() => openAddMemberModal(undefined, s.adminCompanyId)), + ), + ], + sysDefaults: (s) => [ + section( + L("B01_Dashboard_SystemSettings"), + buildSystemSettingsPanel(s.user, s.adminCompanyId), + true, + ), + ], + sysLogs: (s) => [section(L("B01_Dashboard_AuditLogs"), auditLogTable(s.auditLogs), true)], + me: (s) => [ + section(L("B01_Dashboard_Profile"), buildProfileForm(s.user), true), + section(L("B01_Account_Section_Security"), buildSecurityForm(), true), + ], +}; + +export type LegacyKey = keyof typeof SECTIONS; + +/** 옛 구역을 host 에 얹음 — 자료를 다 읽은 뒤 그림 · 그 사이 떠났으면(host 떨어짐) 안 그림 */ +export async function mountLegacy( + key: LegacyKey, + host: HTMLElement, + me: DashboardUser, +): Promise { + const state: DashboardState = { + user: me, + userProjects: [], + companyProjects: [], + allProjects: [], + company: null, + members: [], + joinRequests: [], + allCompanies: [], + allUsers: [], + allJoinRequests: [], + auditLogs: [], + resources: null, + assignedPlans: [], + adminCompanyId: null, + }; + showLoadingOverlay(); + try { + await loadRoleData(state); + } finally { + hideLoadingOverlay(); + } + if (!host.isConnected) return; + const grid = document.createElement("div"); + grid.className = "b01-dashboard__grid"; + grid.append(...SECTIONS[key](state)); + host.append(grid); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Nav.ts b/B01_Dashboard/B01_Dashboard_UI_Nav.ts new file mode 100644 index 000000000..9ef83c3ca --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Nav.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B01_Dashboard_UI_Nav.ts + * 대시보드 왼쪽 메뉴(546) — 묶음(작업 · 회사 관리 · 시스템 관리 · 도움) · 권한 없는 항목 · 빈 묶음 숨김 + * 항목 = 주소 링크(뒤로 가기 · 새 탭) · 지금 위치 강조 · 오른쪽 수 배지 · 작은 화면 = 아이콘 레일(CSS) + * 도움 묶음 페이지(가이드 · 챗봇)도 여기 — 창을 여는 일만 함 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { icon } from "@ui/ui_template_icons"; +import { createHelpButton } from "@ui/ui_template_help"; +import type { View, ViewGroup } from "./B01_Dashboard_UI_Views"; + +const GROUPS: [ViewGroup, string][] = [ + ["work", "작업"], + ["company", "회사 관리"], + ["system", "시스템 관리"], + ["help", "도움"], +]; + +export const viewHref = (key: string, params: Record = {}): string => + `#/${ROUTES.B01_ACCOUNT}?${new URLSearchParams({ view: key, ...params })}`; + +export interface DashboardNav { + root: HTMLElement; + setActive: (key: string) => void; + /** 수 배지 — 0 이하면 숨김 · alert = 빨강(처리할 일) */ + setBadge: (key: string, count: number, alert?: boolean) => void; +} + +/** views = 이 역할에 보이는 것만 받음 */ +export function buildNav(views: View[]): DashboardNav { + const root = document.createElement("nav"); + root.className = "b01-shell__nav"; + root.setAttribute("aria-label", "대시보드 메뉴"); + const items = new Map(); + for (const [group, label] of GROUPS) { + const inGroup = views.filter((v) => v.group === group); + if (!inGroup.length) continue; + const head = document.createElement("div"); + head.className = "b01-shell__nav-group"; + head.textContent = label; + root.append(head); + for (const view of inGroup) { + const a = document.createElement("a"); + a.className = "b01-shell__nav-item"; + a.href = viewHref(view.key); + a.title = view.title; + a.dataset.view = view.key; + const text = document.createElement("span"); + text.className = "b01-shell__nav-label"; + text.textContent = view.title; + const badge = document.createElement("span"); + badge.className = "b01-shell__badge"; + badge.hidden = true; + a.append(icon(view.icon ?? "home"), text, badge); + root.append(a); + items.set(view.key, a); + } + } + return { + root, + setActive: (key) => { + for (const [k, a] of items) { + a.classList.toggle("is-active", k === key); + if (k === key) a.setAttribute("aria-current", "page"); + else a.removeAttribute("aria-current"); + } + }, + setBadge: (key, count, alert = false) => { + const badge = items.get(key)?.querySelector(".b01-shell__badge"); + if (!badge) return; + badge.hidden = count <= 0; + badge.textContent = String(count); + badge.classList.toggle("is-alert", alert); + }, + }; +} + +function helpCard(text: string, action: HTMLElement): HTMLElement { + const card = document.createElement("div"); + card.className = "b01-shell__card b01-shell__help"; + const p = document.createElement("p"); + p.textContent = text; + card.append(p, action); + return card; +} + +/** 사용자 가이드 — 위 머리 [?] 와 같은 창을 엶 */ +export function mountGuide(host: HTMLElement): void { + const button = createHelpButton(); + host.append(helpCard("대시보드 사용법을 그림과 함께 봅니다.", button)); + button.click(); +} + +/** 지식 챗봇 — 오른쪽 아래 둥근 단추 창을 엶 */ +export function mountChat(host: HTMLElement): void { + const toggle = (): HTMLButtonElement | null => + document.querySelector(".b01-knowledge-chat__toggle"); + const open = (): void => { + const t = toggle(); + if (t?.getAttribute("aria-expanded") !== "true") t?.click(); + }; + const button = createButton({ label: "챗봇 열기", variant: "filled", onClick: open }); + host.append(helpCard("임도 · 사방 법령과 기술 자료를 물어봅니다.", button)); + open(); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 5194737e0..08d91f13f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -1,368 +1,32 @@ -import { ROUTES } from "@config/config_frontend"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { - createButton, - createTag, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { section } from "@ui/ui_template_general_blocks"; -import { createHelpButton } from "@ui/ui_template_help"; -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, - fetchAllUsers, - fetchAuditLogs, - fetchCompanyJoinRequests, - fetchCompanyMembers, - fetchCompanyProjects, - fetchDashboardMe, - fetchSystemResources, - fetchUserCompany, - fetchUserProjects, - type AuditLog, - type CompanyInfo, - type DashboardUser, - type JoinRequest, - type Member, - type ProjectItem, - type ResourceData, -} from "./B01_Dashboard_Api_Fetch"; -import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; -import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites"; -import { L, roleLabel } from "./B01_Dashboard_UI_Common"; -import { - buildCompanyPanel, - companyTable, - joinRequestTable, - memberTable, -} from "./B01_Dashboard_UI_Company"; -import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals"; -import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile"; -import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; -import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; -import { - assignedPlanTable, - fetchAssignedPlans, - type AssignedPlan, -} from "./B01_Dashboard_UI_AssignedPlans"; -import { projectTable } from "./B01_Dashboard_UI_Projects"; -import { buildResourcePanel } from "./B01_Dashboard_UI_Resources"; -import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload"; -import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; +/* ============================================================================= + * B01_Dashboard_UI_Page.ts + * 대시보드 입구 — 나(me) · 산림청 판정을 읽고 셸(`B01_Dashboard_UI_Shell.ts`)을 세움(546). + * 세부 페이지는 등록부(`B01_Dashboard_UI_Views.ts`) · 옛 구역 부품은 `B01_Dashboard_UI_Legacy.ts`. + * ========================================================================== */ + +import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import type { MountedPage } from "../A00_Common/router"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { fetchDashboardMe } from "./B01_Dashboard_Api_Fetch"; +import { L } from "./B01_Dashboard_UI_Common"; +import { mountDashboardShell } from "./B01_Dashboard_UI_Shell"; import "./B01_Dashboard_UI_Style.css"; import "./B01_Dashboard_UI_Style_Company.css"; -export interface DashboardState { - user: DashboardUser; - userProjects: ProjectItem[]; - companyProjects: ProjectItem[]; - allProjects: ProjectItem[]; - company: CompanyInfo | null; - members: Member[]; - joinRequests: JoinRequest[]; - allCompanies: CompanyInfo[]; - allUsers: DashboardUser[]; - allJoinRequests: JoinRequest[]; - auditLogs: AuditLog[]; - resources: ResourceData | null; - /** 우리 회사에 지정된 예정노선(528-2) — 회사관리자 · 사용자만 */ - assignedPlans: AssignedPlan[]; - /** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */ - adminCompanyId: number | null; -} +export type { DashboardState } from "./B01_Dashboard_UI_Legacy"; -/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */ -const ADMIN_COMPANY_KEY = "aislo.adminCompany"; - -/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */ -function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null { - const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY)); - if (companies.some((c) => c.id === saved)) return saved; - return user.company_id ?? companies[0]?.id ?? null; -} - -export async function renderB01Dashboard(root: HTMLElement): Promise { +export async function renderB01Dashboard(root: HTMLElement): Promise { showLoadingOverlay(); try { - const user = await fetchDashboardMe(); - const state: DashboardState = { - user, - userProjects: [], - companyProjects: [], - allProjects: [], - company: null, - members: [], - joinRequests: [], - allCompanies: [], - allUsers: [], - allJoinRequests: [], - auditLogs: [], - resources: null, - assignedPlans: [], - adminCompanyId: null, - }; - await loadRoleData(state); + const [user, session] = await Promise.all([ + fetchDashboardMe(), + fetchSessionUser().catch(() => null), + ]); root.innerHTML = ""; - root.append(buildPage(state)); + return await mountDashboardShell(root, user, Boolean(session?.is_forest_service)); } catch (error) { showToast(error instanceof Error ? error.message : L("B01_Dashboard_LoadFailed"), "error"); } finally { hideLoadingOverlay(); } } - -async function loadRoleData(state: DashboardState): Promise { - if (state.user.role === "SYSTEM_ADMIN") { - // 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1) - state.allCompanies = await fetchAllCompanies().catch(() => []); - const companyId = pickAdminCompany(state.user, state.allCompanies); - state.adminCompanyId = companyId; - const results = await Promise.allSettled([ - fetchAllUsers(companyId), - fetchCompanyJoinRequests(companyId), - fetchAuditLogs(), - fetchSystemResources(7), - fetchCompanyProjects(companyId), - ]); - state.allUsers = results[0].status === "fulfilled" ? results[0].value : []; - state.allJoinRequests = results[1].status === "fulfilled" ? results[1].value : []; - state.auditLogs = results[2].status === "fulfilled" ? results[2].value : []; - state.resources = results[3].status === "fulfilled" ? results[3].value : null; - state.allProjects = results[4].status === "fulfilled" ? results[4].value : []; - - results.forEach((res, idx) => { - if (res.status === "rejected") { - console.error(`System Admin API failed at index ${idx}:`, res.reason); - } - }); - return; - } - const [projects, company, assigned] = await Promise.all([ - fetchUserProjects().catch(() => []), - fetchUserCompany().catch(() => null), - fetchAssignedPlans().catch(() => []), - ]); - state.userProjects = projects; - state.company = company; - state.assignedPlans = assigned; - if (state.user.role === "ADMIN") { - const [members, requests, companyProjects] = await Promise.all([ - fetchCompanyMembers().catch(() => []), - fetchCompanyJoinRequests().catch(() => []), - fetchCompanyProjects().catch(() => []), - ]); - state.members = members; - state.joinRequests = requests; - state.companyProjects = companyProjects; - } -} - -/** 설정 카드 공용 양식 — 제목 + 한 줄 설명 · 누르면 이동 */ -function settingCard(title: string, desc: string, go: () => void): HTMLElement { - const card = document.createElement("button"); - card.type = "button"; - card.className = "b01-dashboard__setting-card"; - const head = document.createElement("strong"); - head.className = "b01-dashboard__setting-title"; - head.textContent = title; - const sub = document.createElement("span"); - sub.className = "b01-dashboard__setting-desc"; - sub.textContent = desc; - card.append(head, sub); - card.addEventListener("click", go); - return card; -} - -/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */ -function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b01-dashboard__setting-grid"; - wrap.append( - ...settingLinks(user, companyId).map((link) => - settingCard(link.title, link.desc, () => - link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query), - ), - ), - ); - return wrap; -} - -/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */ -function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement { - const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-"; - const pill = document.createElement("button"); - pill.type = "button"; - pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill"; - pill.title = L("B01_Dashboard_PickCompany"); - pill.textContent = `${name} ▾`; - pill.addEventListener("click", (event) => { - // 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음 - event.stopPropagation(); - openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => { - sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id)); - window.dispatchEvent(new HashChangeEvent("hashchange")); - }); - }); - card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill); - return card; -} - -/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */ -function buildSystemSettingsSection(state: DashboardState): HTMLElement { - const { user } = state; - const panel = buildSystemSettingsPanel(user, state.adminCompanyId); - const card = section(L("B01_Dashboard_SystemSettings"), panel, true); - if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state); - card.querySelector(".ui-card__body")?.classList.add("ui-collapsible"); - card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title"); - attachCollapsible(card); - return card; -} - -/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */ -const SECTION_ICONS: [Parameters[0], string][] = [ - ["B01_Dashboard_Resources", "📊"], - ["B01_Dashboard_SystemSettings", "⚙️"], - ["B01_Dashboard_Projects", "🗂️"], - ["DE_Assigned_Title", "📬"], - ["B01_Temp_Section", "📦"], - ["B01_Dashboard_Users", "👥"], - ["B01_Dashboard_Members", "👥"], - ["B01_Dashboard_JoinRequests", "📨"], - ["B01_Dashboard_Companies", "🏢"], - ["B01_Dashboard_Company", "🏢"], - ["B01_Dashboard_Profile", "👤"], - ["B01_Account_Section_Security", "🔒"], - ["B01_Dashboard_AuditLogs", "📜"], -]; - -/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */ -function mountSectionShortcuts(grid: HTMLElement): void { - // 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음 - if (currentRoute() !== ROUTES.B01_ACCOUNT) return; - const sections = [...grid.children] as HTMLElement[]; - const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? ""); - const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? ""); - const bar = createStepBar(titles, -1, { - orientation: "vertical", - icons, - routes: titles, - onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }), - }); - setDrawerSteps(bar); -} - -/** 받은 예정노선 구역 — 받은 것이 있을 때만 */ -function assignedPlanSection(state: DashboardState): HTMLElement[] { - if (!state.assignedPlans.length) return []; - return [section(L("DE_Assigned_Title"), assignedPlanTable(state.assignedPlans), true)]; -} - -function buildPage(state: DashboardState): HTMLElement { - // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. - const page = document.createElement("div"); - - const grid = document.createElement("div"); - grid.className = "b01-dashboard__grid"; - - if (state.user.role === "SYSTEM_ADMIN") { - // 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것 - const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId); - grid.append( - section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), - buildSystemSettingsSection(state), - withCompanyPill( - section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ - createButton({ label: "+", onClick: () => void openNewProjectPicker() }), - ]), - state, - ), - buildTempUploadSection(), - withCompanyPill( - section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ - createButton({ - label: "+", - onClick: () => openAddMemberModal(undefined, state.adminCompanyId), - }), - ]), - state, - ), - withCompanyPill( - section( - L("B01_Dashboard_JoinRequests"), - joinRequestTable(state.allJoinRequests, true), - true, - ), - state, - ), - withCompanyPill( - section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ - createButton({ label: "+", onClick: () => openCreateCompanyModal() }), - ]), - state, - ), - ); - } else if (state.user.role === "ADMIN") { - // 시스템 설정은 맨 위 한 줄(27-3a) - grid.append( - buildSystemSettingsSection(state), - section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ - createButton({ label: "+", onClick: () => void openNewProjectPicker() }), - ]), - ...assignedPlanSection(state), - buildTempUploadSection(), - section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [ - createButton({ label: "+", onClick: () => openAddMemberModal() }), - ]), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.joinRequests, false), true), - section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), - ); - } else { - grid.append( - buildSystemSettingsSection(state), - section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ - createButton({ label: "+", onClick: () => void openNewProjectPicker() }), - ]), - ...assignedPlanSection(state), - // 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록 - // 바로 아래에 둔다(2026-08-08 사용자 지시). - buildTempUploadSection(), - section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true), - ); - } - - grid.append( - section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), - section(L("B01_Account_Section_Security"), buildSecurityForm()), - ); - // 시스템 로그는 맨 아래 (2026-09-06 사용자 지시). - if (state.user.role === "SYSTEM_ADMIN") { - grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); - } - page.append(grid); - mountSectionShortcuts(grid); - page.append(buildKnowledgeChat()); - const layout = createGeneralLayout({ - pageClass: "b01-dashboard", - title: L("B01_Dashboard_Title"), - subtitle: L("B01_Dashboard_Subtitle"), - content: page, - }); - // 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다). - const tag = createTag( - roleLabel(state.user.role), - state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral", - ); - // [?] 사용자 가이드(538-2) = 배지 왼쪽 — 같은 자리 묶음 - const head = document.createElement("span"); - head.className = "b01-dashboard__role ui-help-head"; - head.append(createHelpButton(), tag); - layout.root.querySelector(".ui-general-layout__header")?.append(head); - return layout.root; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Shell.ts b/B01_Dashboard/B01_Dashboard_UI_Shell.ts new file mode 100644 index 000000000..41f5aa0d4 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Shell.ts @@ -0,0 +1,276 @@ +/* ============================================================================= + * B01_Dashboard_UI_Shell.ts + * 대시보드 셸(546) — 위 머리 48 + 왼쪽 메뉴 210 + 세부 페이지 자리 + 오른쪽 아래 챗봇 단추. + * 머리 = AISLO · 회사 이름(시스템 관리자 = 눌러 회사 전환) · 찾기(자리만) · [?] · 알림 · 사용자 메뉴 + * 페이지 = 등록부(`B01_Dashboard_UI_Views.ts`) · 주소 `?view=` 가 바뀌면 셸은 두고 페이지만 갈아 끼움 + * (라우터 MountedPage — 같은 주소 안 이동 = show) · 옛 ☰ 서랍 · 구역 접기 없음 + * ========================================================================== */ + +import { LANG_STORAGE_KEY, ROUTES, THEME_STORAGE_KEY } from "@config/config_frontend"; +import { createHelpButton } from "@ui/ui_template_help"; +import { icon, type IconName } from "@ui/ui_template_icons"; +import { currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; +import { readTouchMode, setTouchMode, type TouchMode } from "../A00_Common/device_context"; +import { currentQuery, navigateTo, type MountedPage } from "../A00_Common/router"; +import { fetchAllCompanies, type CompanyInfo, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { roleLabel } from "./B01_Dashboard_UI_Common"; +import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick"; +import { buildKnowledgeChat } from "./B01_Dashboard_UI_Knowledge_Chat"; +import { ADMIN_COMPANY_KEY, pickAdminCompany } from "./B01_Dashboard_UI_Legacy"; +import { buildNav, viewHref } from "./B01_Dashboard_UI_Nav"; +import { roleOf, viewFor, VIEWS, type ViewCtx } from "./B01_Dashboard_UI_Views"; +import "./B01_Dashboard_UI_Style_Shell.css"; + +function iconButton(name: IconName, label: string, onClick?: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.type = "button"; + b.className = "b01-shell__icon-btn"; + b.title = b.ariaLabel = label; + b.append(icon(name)); + if (onClick) b.addEventListener("click", onClick); + else b.disabled = true; + return b; +} + +function menuItem(name: IconName, label: string, onClick: () => void): HTMLButtonElement { + const b = document.createElement("button"); + b.type = "button"; + b.className = "b01-shell__menu-item"; + const text = document.createElement("span"); + text.textContent = label; + b.append(icon(name, 16), text); + b.addEventListener("click", onClick); + return b; +} + +function toggleTheme(): void { + const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + localStorage.setItem(THEME_STORAGE_KEY, next); +} + +/** 로그아웃 — 옛 ☰ 와 같음(서버 로그아웃 → 이 브라우저 자료 지움 → 홈으로 새로고침) */ +async function logoutNow(): Promise { + const { logout } = await import("../A06_Login/A06_Login_Api_Fetch"); + await logout().catch(() => undefined); // 세션이 이미 만료된 경우 등은 무시 + const { wipeAccountCache } = await import("../A00_Common/b_project_switch"); + await wipeAccountCache(); + history.replaceState(null, "", `#/${ROUTES.A01_HOME}`); + window.location.reload(); +} + +/** 사용자 메뉴 — 이름 · 역할 · 내 정보 · 화면(밝음 · 어두움 · 언어 · 터치) · 설정 · 홈페이지 · 로그아웃 */ +function buildUserMenu(ctx: ViewCtx, roleText: string): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b01-shell__user"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "b01-shell__user-btn"; + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "false"); + const avatar = document.createElement("span"); + avatar.className = "b01-shell__avatar"; + avatar.textContent = (ctx.me.name?.trim() || ctx.me.email).slice(0, 1); + const name = document.createElement("span"); + name.className = "b01-shell__user-name"; + name.textContent = ctx.me.name?.trim() || ctx.me.email; + trigger.append(avatar, name, icon("chevronDown", 14)); + + const menu = document.createElement("div"); + menu.className = "b01-shell__menu"; + menu.setAttribute("role", "menu"); + menu.hidden = true; + const who = document.createElement("div"); + who.className = "b01-shell__menu-who"; + const strong = document.createElement("strong"); + strong.textContent = name.textContent; + const sub = document.createElement("span"); + sub.textContent = `${roleText} · ${ctx.me.email}`; + who.append(strong, sub); + + const touch = document.createElement("div"); + touch.className = "b01-shell__menu-touch"; + const touchLabel = document.createElement("span"); + touchLabel.textContent = "터치 모드"; + touch.append(icon("hand", 16), touchLabel); + const modes: [TouchMode, string][] = [ + ["auto", "자동"], + ["on", "켬"], + ["off", "끔"], + ]; + for (const [mode, label] of modes) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + b.dataset.touchMode = mode; + b.setAttribute("aria-pressed", String(readTouchMode() === mode)); + b.addEventListener("click", () => { + setTouchMode(mode); + for (const x of touch.querySelectorAll("button")) + x.setAttribute("aria-pressed", String(x.dataset.touchMode === mode)); + }); + touch.append(b); + } + + const setOpen = (open: boolean): void => { + menu.hidden = !open; + trigger.setAttribute("aria-expanded", String(open)); + }; + const dark = document.documentElement.getAttribute("data-theme") === "dark"; + menu.append( + who, + menuItem("userCircle", "내 정보", () => ctx.go("me")), + menuItem(dark ? "sun" : "moon", dark ? "밝은 화면" : "어두운 화면", () => { + toggleTheme(); + setOpen(false); + }), + menuItem("world", currentLanguageIndex === 0 ? "English" : "한국어", () => { + const next = currentLanguageIndex === 0 ? "en" : "ko"; + setLanguage(next); + localStorage.setItem(LANG_STORAGE_KEY, next); + window.location.reload(); + }), + touch, + menuItem("adjustments", "설정(마우스 · 단축키)", () => { + void import("../A00_Common/app_settings").then((m) => m.openAppSettings()); + }), + menuItem("home", "홈페이지", () => navigateTo(ROUTES.A01_HOME)), + menuItem("logout", "로그아웃", () => void logoutNow()), + ); + // 고른 뒤 닫힘(터치 모드 단추는 그대로) + menu.addEventListener("click", (e) => { + if ((e.target as Element).closest(".b01-shell__menu-item")) setOpen(false); + }); + trigger.addEventListener("click", () => setOpen(menu.hidden !== false)); + const outside = (e: MouseEvent): void => { + if (!wrap.isConnected) document.removeEventListener("click", outside); + else if (!wrap.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("click", outside); + wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); + wrap.append(trigger, menu); + return wrap; +} + +/** 회사 이름 — 시스템 관리자는 눌러 회사 고르기(옛 회사 알약) · 고르면 지금 페이지를 그 회사 것으로 다시 그림 */ +function buildCompany( + ctx: ViewCtx, + companies: CompanyInfo[], + onPick: (id: number) => void, +): HTMLElement { + const nameOf = (): string => + companies.find((c) => c.id === ctx.companyId)?.name ?? ctx.me.company_name ?? "-"; + if (ctx.role !== "SYSTEM_ADMIN") { + const span = document.createElement("span"); + span.className = "b01-shell__org"; + span.textContent = ctx.me.company_name ?? "회사 없음"; + return span; + } + const b = document.createElement("button"); + b.type = "button"; + b.className = "b01-shell__org is-switch"; + b.title = "회사 전환"; + const label = document.createElement("span"); + label.textContent = nameOf(); + b.append(label, icon("chevronDown", 14)); + b.addEventListener("click", () => + openCompanyPicker(companies, ctx.companyId, (company) => { + sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id)); + onPick(company.id); + label.textContent = nameOf(); + }), + ); + return b; +} + +export async function mountDashboardShell( + root: HTMLElement, + me: DashboardUser, + forest: boolean, +): Promise { + const role = roleOf(me, forest); + const companies = role === "SYSTEM_ADMIN" ? await fetchAllCompanies().catch(() => []) : []; + const ctx: ViewCtx = { + me, + role, + companyId: role === "SYSTEM_ADMIN" ? pickAdminCompany(me, companies) : (me.company_id ?? null), + go: (view, params = {}) => { + window.location.hash = viewHref(view, params); + }, + }; + const roleText = role === "FOREST" ? "산림청" : roleLabel(me.role); + + const shell = document.createElement("div"); + shell.className = "b01-shell b01-dashboard"; + + const header = document.createElement("header"); + header.className = "b01-shell__head"; + const brand = document.createElement("a"); + brand.className = "b01-shell__brand"; + brand.href = viewHref("home"); + brand.textContent = "AISLO"; + const search = document.createElement("input"); + search.className = "b01-shell__search"; + search.type = "search"; + search.placeholder = "찾기 (준비 중)"; + search.disabled = true; + const spacer = document.createElement("span"); + spacer.className = "b01-shell__spacer"; + const help = createHelpButton(); + help.classList.add("b01-shell__help-btn"); + const bell = iconButton("bell", "알림 (준비 중)"); + bell.classList.add("b01-shell__bell"); + header.append( + brand, + buildCompany(ctx, companies, (id) => { + ctx.companyId = id; + show(); + }), + search, + spacer, + help, + bell, + buildUserMenu(ctx, roleText), + ); + + const visible = VIEWS.filter((v) => v.roles.includes(role)); + const nav = buildNav(visible); + const main = document.createElement("main"); + main.className = "b01-shell__main"; + const body = document.createElement("div"); + body.className = "b01-shell__body"; + body.append(nav.root, main); + shell.append(header, body, buildKnowledgeChat()); + root.append(shell); + + let cleanup: (() => void) | null = null; + let seq = 0; + /** 주소 `?view=` 페이지를 새 자리에 그림 — 옛 페이지 정리 · 늦게 끝난 옛 mount 의 정리도 부름 */ + const show = (): void => { + cleanup?.(); + cleanup = null; + const mine = ++seq; + const view = viewFor(currentQuery().get("view"), role); + nav.setActive(view.group === "hidden" && view.key === "project" ? "projects" : view.key); + const host = document.createElement("div"); + host.className = "b01-shell__page"; + host.dataset.view = view.key; + main.replaceChildren(host); + void Promise.resolve(view.mount(host, ctx)).then((done) => { + if (typeof done !== "function") return; + if (mine === seq) cleanup = done; + else done(); + }); + }; + show(); + + return { + covers: (route) => route === ROUTES.B01_ACCOUNT, + show, + dispose: () => { + seq++; + cleanup?.(); + cleanup = null; + }, + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css new file mode 100644 index 000000000..3c46cdb83 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Shell.css @@ -0,0 +1,353 @@ +/* 대시보드 셸(546) — 색 · 간격은 .b01-shell 안 변수만(프로젝트 화면 영향 0) · 간격 = 4 의 배수 */ +.b01-shell { + --b01-accent: #1f7a4d; + --b01-accent-soft: #eaf5ee; + --b01-accent-text: #ffffff; + --b01-bg: #f6f7f9; + --b01-card: #ffffff; + --b01-line: #e6e8ec; + --b01-text: #1f2328; + --b01-muted: #6b7280; + --b01-hover: #f3f4f6; + --b01-alert: #e5484d; + --b01-radius: 10px; + --b01-head-h: 48px; + --b01-nav-w: 210px; + --b01-rail-w: 56px; + + /* 옛 구역 부품(공용 카드 · 표 · 단추)도 셸 색을 따름 — 셸 안에서만 */ + --color-bg: var(--b01-bg); + --color-surface: var(--b01-card); + --color-surface-raised: var(--b01-card); + --color-border: var(--b01-line); + --color-primary: var(--b01-accent); + --color-primary-text: var(--b01-accent-text); + + min-height: 100dvh; + display: grid; + grid-template-rows: var(--b01-head-h) 1fr; + background: var(--b01-bg); + color: var(--b01-text); +} + +:root[data-theme="dark"] .b01-shell { + --b01-accent: #3fae75; + --b01-accent-soft: #173326; + --b01-accent-text: #0e1512; + --b01-bg: #121417; + --b01-card: #1b1e23; + --b01-line: #2c3038; + --b01-text: #e6e8eb; + --b01-muted: #9aa1ab; + --b01-hover: #242830; + --b01-alert: #f06b6f; +} + +/* 옛 ☰ 서랍 · 떠 있는 단추 — 대시보드에선 걷음(왼쪽 메뉴 · 사용자 메뉴가 맡음) */ +html:has(.b01-shell) .app-header { + display: none; +} + +/* 위 머리 */ +.b01-shell__head { + position: sticky; + top: 0; + z-index: var(--z-header); + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + background: var(--b01-card); + border-bottom: 1px solid var(--b01-line); +} +.b01-shell__brand { + font-weight: 800; + letter-spacing: 0.6px; + color: var(--b01-text); + text-decoration: none; +} +.b01-shell__org { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 220px; + padding: 4px 8px; + border: 1px solid var(--b01-line); + border-radius: 8px; + background: none; + color: var(--b01-muted); + font: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.b01-shell__org.is-switch { + cursor: pointer; +} +.b01-shell__org.is-switch:hover { + background: var(--b01-hover); + color: var(--b01-text); +} +.b01-shell__search { + width: 240px; + padding: 4px 12px; + border: 1px solid var(--b01-line); + border-radius: 8px; + background: var(--b01-bg); + color: var(--b01-text); + font: inherit; +} +.b01-shell__spacer { + flex: 1; +} +.b01-shell__icon-btn { + position: relative; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 1px solid var(--b01-line); + border-radius: 8px; + background: var(--b01-card); + color: var(--b01-muted); + cursor: pointer; +} +.b01-shell__icon-btn:disabled { + cursor: default; +} +.b01-shell__icon-btn:not(:disabled):hover { + background: var(--b01-hover); + color: var(--b01-text); +} +.b01-shell__icon-btn .b01-shell__badge { + position: absolute; + top: -6px; + right: -6px; + margin: 0; +} +.ui-icon { + display: inline-grid; + place-items: center; + flex: none; +} + +/* 사용자 메뉴 */ +.b01-shell__user { + position: relative; +} +.b01-shell__user-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 8px 4px 4px; + border: 0; + border-radius: 8px; + background: none; + color: var(--b01-text); + font: inherit; + cursor: pointer; +} +.b01-shell__user-btn:hover { + background: var(--b01-hover); +} +.b01-shell__avatar { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--b01-accent-soft); + color: var(--b01-accent); + font-weight: 700; + font-size: 12px; +} +.b01-shell__user-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.b01-shell__menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + min-width: 240px; + padding: 4px; + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12); +} +.b01-shell__menu[hidden] { + display: none; +} +.b01-shell__menu-who { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px 12px; + border-bottom: 1px solid var(--b01-line); + margin-bottom: 4px; +} +.b01-shell__menu-who span { + color: var(--b01-muted); + font-size: 12px; +} +.b01-shell__menu-item, +.b01-shell__menu-touch { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 12px; + border: 0; + border-radius: 8px; + background: none; + color: var(--b01-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.b01-shell__menu-item:hover { + background: var(--b01-hover); +} +.b01-shell__menu-touch { + cursor: default; +} +.b01-shell__menu-touch > span:nth-child(2) { + flex: 1; +} +.b01-shell__menu-touch button { + padding: 2px 8px; + border: 1px solid var(--b01-line); + border-radius: 12px; + background: var(--b01-card); + color: var(--b01-muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.b01-shell__menu-touch button[aria-pressed="true"] { + border-color: var(--b01-accent); + background: var(--b01-accent-soft); + color: var(--b01-accent); +} + +/* 몸통 — 왼쪽 메뉴 + 페이지 */ +.b01-shell__body { + display: grid; + grid-template-columns: var(--b01-nav-w) minmax(0, 1fr); +} +.b01-shell__nav { + position: sticky; + top: var(--b01-head-h); + height: calc(100dvh - var(--b01-head-h)); + overflow-y: auto; + padding: 8px; + background: var(--b01-card); + border-right: 1px solid var(--b01-line); +} +.b01-shell__nav-group { + margin: 12px 8px 4px; + color: var(--b01-muted); + font-size: 12px; +} +.b01-shell__nav-item { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 8px; + border-radius: 8px; + color: var(--b01-text); + text-decoration: none; +} +.b01-shell__nav-item:hover { + background: var(--b01-hover); +} +.b01-shell__nav-item.is-active { + background: var(--b01-accent-soft); + color: var(--b01-accent); + font-weight: 600; +} +.b01-shell__nav-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.b01-shell__badge { + min-width: 20px; + padding: 0 4px; + border-radius: 8px; + background: var(--b01-hover); + color: var(--b01-muted); + font-size: 11px; + font-weight: 600; + line-height: 16px; + text-align: center; +} +.b01-shell__badge.is-alert { + background: var(--b01-alert); + color: #fff; +} +.b01-shell__badge[hidden] { + display: none; +} +.b01-shell__main { + min-width: 0; + padding: 20px 24px 32px; +} + +/* 도움 페이지 카드 */ +.b01-shell__card { + padding: 16px; + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); +} +.b01-shell__help { + display: flex; + align-items: center; + gap: 16px; + max-width: 640px; +} +.b01-shell__help p { + flex: 1; + margin: 0; +} + +/* 작은 화면 — 메뉴는 아이콘 레일 · 머리 찾기 칸 · 이름 숨김 */ +@media (max-width: 1024px) { + .b01-shell__body { + grid-template-columns: var(--b01-rail-w) minmax(0, 1fr); + } + .b01-shell__nav-group { + height: 1px; + margin: 8px 4px; + overflow: hidden; + background: var(--b01-line); + color: transparent; + } + .b01-shell__nav-item { + position: relative; + justify-content: center; + padding: 0; + } + .b01-shell__nav-label { + display: none; + } + .b01-shell__nav-item .b01-shell__badge { + position: absolute; + top: 0; + right: 0; + } + .b01-shell__search, + .b01-shell__user-name { + display: none; + } +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts new file mode 100644 index 000000000..3297a73a9 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B01_Dashboard_UI_Views.ts + * 대시보드 세부 페이지 등록부(546) — 한 줄 = 한 페이지 · 주소 `#/b01-account?view=<열쇠>&…`. + * key = 주소 열쇠 · title = 메뉴 · 머리 글 · group = 메뉴 묶음(hidden = 메뉴에 안 보임 · 주소로만) + * roles = 보이는 역할(없는 역할은 메뉴에서 숨고 주소로 와도 홈) · icon = ui_template_icons 이름 + * mount(host, ctx) = host 에 그림 · 정리 함수를 돌려주면 떠날 때 부름(비동기 가능) + * ctx = { me, role, companyId, go(view, params?) } — companyId = 시스템 관리자는 머리에서 고른 회사 + * 새 페이지는 자기 줄만 갈아 끼움 · 옛 구역(`mountLegacy`)은 갈아 끼우면 지움. + * ========================================================================== */ + +import type { IconName } from "@ui/ui_template_icons"; +import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy"; +import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav"; + +/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */ +export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN"; +export type ViewGroup = "work" | "company" | "system" | "help" | "hidden"; + +export interface ViewCtx { + me: DashboardUser; + role: ViewRole; + companyId: number | null; + go: (view: string, params?: Record) => void; +} + +type Cleanup = (() => void) | void; + +export interface View { + key: string; + title: string; + group: ViewGroup; + roles: readonly ViewRole[]; + icon?: IconName; + mount: (host: HTMLElement, ctx: ViewCtx) => Cleanup | Promise; +} + +export const DEFAULT_VIEW = "home"; + +const ALL: readonly ViewRole[] = ["USER", "ADMIN", "FOREST", "SYSTEM_ADMIN"]; +const legacy = + (key: LegacyKey) => + (host: HTMLElement, ctx: ViewCtx): Promise => + mountLegacy(key, host, ctx.me); + +// prettier-ignore +export const VIEWS: View[] = [ + // 작업 + { key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") }, + { key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") }, + { key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") }, + { key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") }, + { key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: legacy("temp") }, + // 회사 관리 + { key: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: legacy("people") }, + { key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: legacy("company") }, + { key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: legacy("companyDefaults") }, + // 시스템 관리 + { key: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: legacy("sysOverview") }, + { key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: legacy("sysCompanies") }, + { key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: legacy("sysUsers") }, + { key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: legacy("sysDefaults") }, + { key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: legacy("sysLogs") }, + // 도움 + { key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide }, + { key: "chat", title: "지식 챗봇", group: "help", roles: ALL, icon: "chatbot", mount: mountChat }, + // 사용자 메뉴 + { key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: legacy("me") }, +]; + +/** 역할 가르기 — 옛 B01 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */ +export function roleOf(me: DashboardUser, forest: boolean): ViewRole { + if (me.role === "SYSTEM_ADMIN" || me.role === "ADMIN") return me.role; + return forest ? "FOREST" : "USER"; +} + +/** 주소 열쇠 → 페이지 · 없거나 권한 없으면 홈 */ +export function viewFor(key: string | null, role: ViewRole): View { + const found = VIEWS.find((v) => v.key === key && v.roles.includes(role)); + return found ?? VIEWS.find((v) => v.key === DEFAULT_VIEW)!; +} diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index ab8693852..ecd542cbc 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -264,7 +264,7 @@ def test_카드_시스템_관리자만_글_다_있음(): admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =") assert 'key: "calcSites"' in admin and "calcSites" not in rest assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav - page = _text("B01_Dashboard/B01_Dashboard_UI_Page.ts") + page = _text("B01_Dashboard/B01_Dashboard_UI_Legacy.ts") assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page locale = _text("ui_template/ui_template_locale_m1.ts") for group in calc_sites.GROUPS: diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py new file mode 100644 index 000000000..baf8cfe74 --- /dev/null +++ b/resources/tester/test_546_b01_shell.py @@ -0,0 +1,106 @@ +"""546 대시보드 셸 · 세부 페이지 등록부 시험 — 화면 조작은 ORCA. + +· 역할 넷(일반 · 회사 관리자 · 산림청 · 시스템 관리자)별 메뉴 묶음 · 항목 — 등록부를 Node 로 바로 돌림 +· 없는 열쇠 · 권한 없는 열쇠 = 홈 · 주소 열쇠 하나에 한 페이지 +· 셸 = 라우터 MountedPage(같은 주소 안 이동 = 페이지만 갈아 끼움) · 옛 ☰ 서랍 · 구역 접기 없음 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B01 = ROOT / "B01_Dashboard" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _run_views(body: str) -> object: + src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M) + script = ( + "const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n" + + src + + "\n" + + body + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +MENU = ( + "const menu = (r: ViewRole) => VIEWS.filter((v) => v.roles.includes(r) && v.group !== 'hidden')" + ".map((v) => v.group + ':' + v.key);\n" +) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_역할별_메뉴(): + out = _run_views( + MENU + "console.log(JSON.stringify(['USER','ADMIN','FOREST','SYSTEM_ADMIN'].map(menu)));" + ) + work = ["work:home", "work:projects", "work:assigned", "work:temp"] + help_ = ["help:guide", "help:chat"] + user, admin, forest, system = out + assert user == [*work, "company:company", "company:company-defaults", *help_] + assert admin == [*work, "company:people", "company:company", "company:company-defaults", *help_] + # 산림청 = 받은 예정노선 없음(보내는 쪽) + assert forest == [ + "work:home", "work:projects", "work:temp", + "company:company", "company:company-defaults", *help_, + ] # fmt: skip + assert system == [ + *work, "company:people", "company:company", + "system:sys-overview", "system:sys-companies", "system:sys-users", + "system:sys-defaults", "system:sys-logs", *help_, + ] # fmt: skip + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_주소_열쇠_권한_없으면_홈(): + out = _run_views( + "console.log(JSON.stringify([" + "viewFor(null, 'USER').key, viewFor('없음', 'USER').key," + "viewFor('people', 'USER').key, viewFor('people', 'ADMIN').key," + "viewFor('sys-logs', 'ADMIN').key, viewFor('sys-logs', 'SYSTEM_ADMIN').key," + "viewFor('project', 'USER').key, viewFor('me', 'FOREST').key," + "roleOf({ role: 'USER' }, true), roleOf({ role: 'ADMIN' }, true)," + "roleOf({ role: 'SYSTEM_ADMIN' }, true), roleOf({ role: 'USER' }, false)," + "new Set(VIEWS.map((v) => v.key)).size === VIEWS.length," + "]));" + ) + assert out == [ + "home", "home", "home", "people", "home", "sys-logs", "project", "me", + "FOREST", "ADMIN", "SYSTEM_ADMIN", "USER", True, + ] # fmt: skip + + +def test_셸_한_벌(): + shell = _text(B01 / "B01_Dashboard_UI_Shell.ts") + assert "covers: (route) => route === ROUTES.B01_ACCOUNT" in shell # 같은 주소 안 = show + assert 'currentQuery().get("view")' in shell + assert "buildKnowledgeChat()" in shell # 챗봇 둥근 단추 그대로 + page = _text(B01 / "B01_Dashboard_UI_Page.ts") + assert "mountDashboardShell(root, user, Boolean(session?.is_forest_service))" in page + for name in ("Page", "Shell", "Nav", "Views", "Legacy"): + src = _text(B01 / f"B01_Dashboard_UI_{name}.ts") + assert "setDrawerSteps" not in src and "attachCollapsible" not in src, name + assert len(src.splitlines()) <= 700, name + css = _text(B01 / "B01_Dashboard_UI_Style_Shell.css") + # 색 변수는 셸 안에서만 — :root 에 안 둠 + assert "--b01-accent: #1f7a4d;" in css and not re.search(r"^:root\s*\{", css, re.M) + assert ':root[data-theme="dark"] .b01-shell {' in css + icons = _text(ROOT / "ui_template" / "ui_template_icons.ts") + assert "MIT License" in icons and "Paweł Kuna" in icons diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 4de5cb487..767911f26 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -1,8 +1,7 @@ -"""B01 대시보드 「시스템 설정」 컨테이너 시험(27-3) — 화면 조작은 ORCA. +"""B01 대시보드 설계 기본값 카드 · 회사 고르기 시험(27-3 · 29-1 · 546) — 화면 조작은 ORCA. · 역할별 카드(제목 · 설명 · 이동 해시)를 Node 로 바로 돌림 — 가짜 DOM -· 모든 역할 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음 -· 회사 고르기(29-1) — 시스템 관리자만 · 리소스 현황 아래 · 해시 company · 서버 인자 한 곳 +· 회사 고르기 — 시스템 관리자만 · 위 머리 회사 이름 · 해시 company · 서버 인자 한 곳 """ from __future__ import annotations @@ -16,7 +15,8 @@ from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] -PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts" +PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Legacy.ts" # 옛 구역 부품(546) +SHELL = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Shell.ts" NAV = ROOT / "A00_Common" / "m_settings_nav.ts" # 카드 규칙 한 벌(334) @@ -103,49 +103,23 @@ def test_회사_관리자_개인_카드_하나(): ] -def test_모든_역할_한_줄_통째로_접힘(): +def test_회사_고르기_시스템_관리자만(): src = _text(PAGE) - build = _fn(src, "buildPage") - # 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위 - assert build.count("buildSystemSettingsSection(state)") == 3 - sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] - assert ( - sys_part.index('L("B01_Dashboard_Resources")') - < sys_part.index("buildSystemSettingsSection(state)") - < sys_part.index('L("B01_Dashboard_Projects")') - ) assert "StructureManage" not in src and "buildStructurePanel" not in src - assert "top-row" not in src - sect = _fn(src, "buildSystemSettingsSection") - assert 'L("B01_Dashboard_SystemSettings"), panel, true)' in sect # 한 줄 통째 - assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect - assert "attachCollapsible(card)" in sect config = _text(ROOT / "config" / "config_frontend.ts") assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config) - - -def test_회사_알약_시스템_관리자만(): - src = _text(PAGE) - sect = _fn(src, "buildSystemSettingsSection") - assert 'if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);' in sect - pill = _fn(src, "withCompanyPill") - assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id))" in pill - assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음 - assert "openCompanyPicker(state.allCompanies" in pill # 조회 후 고르기(29-5) - assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양 - assert '.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill)' in pill - assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 + shell = _text(SHELL) + pick = _fn(shell, "buildCompany") + assert 'if (ctx.role !== "SYSTEM_ADMIN") {' in pick # 그 밖 역할 = 이름만 + assert "openCompanyPicker(companies, ctx.companyId" in pick # 조회 후 고르기(29-5) + assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id))" in pick + assert "pickAdminCompany(me, companies)" in shell + assert 'export const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 load = src.split("async function loadRoleData")[1].split("\n}\n")[0] for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)", "fetchCompanyProjects(companyId)"): # fmt: skip assert call in load, call - build = _fn(src, "buildPage") - # 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약 - sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] - assert sys_part.count("withCompanyPill(") == 4 - assert "withCompanyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1] - assert "companyTable(picked, state.user)" in build - assert "openAddMemberModal(undefined, state.adminCompanyId)" in build + assert "openAddMemberModal(undefined, s.adminCompanyId)" in src @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts new file mode 100644 index 000000000..296f2534a --- /dev/null +++ b/ui_template/ui_template_icons.ts @@ -0,0 +1,78 @@ +/* ============================================================================= + * ui_template_icons.ts + * 선 아이콘 한 묶음 — Tabler Icons(https://tabler.io/icons) 에서 필요한 것만 옮김 · npm 묶음 안 들임. + * 쓰기 = `icon("home")` → · 색 = 글 색(currentColor) · 크기 = 인자. + * + * MIT License + * + * Copyright (c) 2020-2024 Paweł Kuna + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * ========================================================================== */ + +/** 아이콘 이름 → 묶음(24 × 24 · 선 2) */ +const PATHS = { + home: "M5 12l-2 0l9 -9l9 9l-2 0|M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7|M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6", + folder: "M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2", + route: + "M3 19a2 2 0 1 0 4 0a2 2 0 0 0 -4 0|M19 7a2 2 0 1 0 0 -4a2 2 0 0 0 0 4z|M11 19h5.5a3.5 3.5 0 0 0 0 -7h-8a3.5 3.5 0 0 1 0 -7h4.5", + archive: + "M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z|M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10|M10 12l4 0", + users: + "M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0|M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2|M16 3.13a4 4 0 0 1 0 7.75|M21 21v-2a4 4 0 0 0 -3 -3.85", + building: + "M3 21l18 0|M9 8l1 0|M9 12l1 0|M9 16l1 0|M14 8l1 0|M14 12l1 0|M14 16l1 0|M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16", + adjustments: + "M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0|M6 4v4|M6 12v8|M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0|M12 4v10|M12 18v2|M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0|M18 4v1|M18 9v11", + gauge: "M10 13a2 2 0 1 0 4 0a2 2 0 1 0 -4 0|M13.45 11.55l2.05 -2.05|M6.4 20a9 9 0 1 1 11.2 0z", + user: "M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0|M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2", + userCircle: + "M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0|M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855", + database: + "M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0|M4 6v6a8 3 0 0 0 16 0v-6|M4 12v6a8 3 0 0 0 16 0v-6", + fileText: + "M14 3v4a1 1 0 0 0 1 1h4|M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z|M9 9l1 0|M9 13l6 0|M9 17l6 0", + book: "M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0|M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0|M3 6l0 13|M12 6l0 13|M21 6l0 13", + chatbot: + "M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z|M9.5 9h.01|M14.5 9h.01|M9.5 13a3.5 3.5 0 0 0 5 0", + bell: "M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6|M9 17v1a3 3 0 0 0 6 0v-1", + search: "M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0|M21 21l-6 -6", + chevronDown: "M6 9l6 6l6 -6", + logout: + "M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2|M9 12h12l-3 -3|M18 15l3 -3", + sun: "M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0|M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7", + moon: "M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z", + world: + "M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0|M3.6 9h16.8|M3.6 15h16.8|M11.5 3a17 17 0 0 0 0 18|M12.5 3a17 17 0 0 1 0 18", + hand: "M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5|M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5|M14 5.5a1.5 1.5 0 0 1 3 0v6.5|M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47", +} as const; + +export type IconName = keyof typeof PATHS; + +export function icon(name: IconName, size = 18): HTMLSpanElement { + const span = document.createElement("span"); + span.className = "ui-icon"; + span.setAttribute("aria-hidden", "true"); + const paths = PATHS[name] + .split("|") + .map((d) => ``) + .join(""); + span.innerHTML = `${paths}`; + return span; +}