diff --git a/B01_Dashboard/B01_Dashboard_Api_Projects.ts b/B01_Dashboard/B01_Dashboard_Api_Projects.ts new file mode 100644 index 000000000..137dadac0 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Projects.ts @@ -0,0 +1,82 @@ +/* ============================================================================= + * B01_Dashboard_Api_Projects.ts + * 프로젝트 목록 · 상세 API(요구 369~374 · PLAN 548) — 서버 `B01_Dashboard_Router_Overview.py`. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { ProjectItem } from "./B01_Dashboard_Api_Fetch"; + +/** 칸반 칸 — 서버 `board_column` 과 같은 열쇠 */ +export type BoardColumn = "start" | "design" | "review" | "confirm" | "done"; + +export interface OverviewProject extends ProjectItem { + user_id?: number | null; + project_year_end?: number | null; + design_speed_kph?: number | null; + created_at?: string | null; + manager_name?: string | null; + source_project_id?: string | null; + stage_total: number; + board: BoardColumn; + idle_days: number | null; + stalled: boolean; + /** 썸네일 선(줄인 평면 좌표) — 547-1 카드 요약 */ + thumb?: [number, number][] | null; +} + +/** 지도 선 — 위경도 [lat, lon] */ +export interface RouteLine { + points: [number, number][]; +} + +export interface ProjectLink { + kind: "source" | "derived" | "plan"; + project_id: string; + name: string; + project_kind?: string | null; + note?: string | null; +} + +export interface ProjectOutput { + key: string; + label: string; + url: string; +} + +export interface ProjectChange { + at: string; + who?: string | null; + action: string; +} + +export interface ProjectDetail { + project: OverviewProject; + people: { pm?: string | null; field_lead?: string | null; designer?: string | null }; + route: RouteLine | null; + numbers: { key: string; value: number; unit?: string }[]; + links: ProjectLink[]; + outputs: ProjectOutput[]; + changes: ProjectChange[]; +} + +async function getJson(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.detail || `요청 실패: ${response.status}`); + return data as T; +} + +export async function fetchOverviewProjects( + scope: "mine" | "company", + companyId?: number | null, +): Promise { + const company = companyId ? `&company_id=${companyId}` : ""; + const data = await getJson<{ projects: OverviewProject[] }>( + `/dashboard/overview/projects?scope=${scope}${company}`, + ); + return data.projects ?? []; +} + +export function fetchProjectDetail(projectId: string): Promise { + return getJson(`/dashboard/overview/projects/${encodeURIComponent(projectId)}`); +} diff --git a/B01_Dashboard/B01_Dashboard_Router_Overview.py b/B01_Dashboard/B01_Dashboard_Router_Overview.py new file mode 100644 index 000000000..b738f7eea --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router_Overview.py @@ -0,0 +1,324 @@ +"""B01 대시보드 프로젝트 목록 · 상세 API (요구 369~374 · PLAN 548). + +목록 = 카드 · 표 · 칸반 한 벌(칸반 칸은 단계 상태로 저절로 · `board_column`). +상세 = 머리 · 단계 · 지도 선 · 핵심 숫자 · 기본 정보 · 연결 · 산출물 · 최근 변경(있는 것만). +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Query + +from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import _project_rows + +router = APIRouter(prefix="/api/dashboard/overview", tags=["B01_Dashboard"]) + +#: 종류별 단계 수 — 화면 단계 막대(`group_workflow.ts` · `WORKFLOW_STEP_ROUTES`)와 같음 +STAGE_TOTAL = {"ROAD": 7, "PLAN": 3, "STABILITY": 6} +#: 손대지 않은 날이 이만큼 넘으면 「오래 멈춤」 +STALE_DAYS = 14 + +_COLUMNS = """p.id, p.user_id, p.company_id, p.name, p.region, p.road_type, p.project_kind, + p.project_year, p.project_year_end, p.design_speed_kph, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.crs_epsg, + p.client_org, p.project_number, p.work_amount, p.design_date, + p.pm_user_id, p.field_lead_user_id, p.designer_user_id, + p.logo_asset_id, p.signature_asset_id, p.source_project_id, + u.name AS owner_name, COALESCE(pm.name, u.name) AS manager_name""" +_FROM = """FROM projects p LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN users pm ON pm.id = p.pm_user_id""" + + +def board_column(kind: str | None, stages: list[dict[str, Any]]) -> str: + """칸반 칸 — start 시작 전 · design 설계 중 · review 검토 · confirm 확정 대기 · done 완료. + + review = 앞 단계가 바뀌어 다시 볼 단계(STALE) · 실패(FAILED)가 있음. + confirm = 앞 단계를 다 마치고 마지막 단계만 남음. + """ + total = STAGE_TOTAL.get(kind or "ROAD", 0) + states = {int(s["stage_no"]): s["state"] for s in stages} + if total and states.get(total - 1) == "COMPLETE": + return "done" + if all(state == "NOT_STARTED" for state in states.values()): + return "start" + if any(state in ("STALE", "FAILED") for state in states.values()): + return "review" + done_head = 0 + while states.get(done_head) == "COMPLETE": + done_head += 1 + if total and done_head >= total - 1: + return "confirm" + return "design" + + +def idle_days(updated_at: datetime | None, now: datetime | None = None) -> int | None: + """마지막 수정 뒤 지난 날 수.""" + if updated_at is None: + return None + return ((now or datetime.now()) - updated_at).days + + +def is_mine(row: dict[str, Any], user_id: int) -> bool: + """내 것 = 만든 사람 · 참여자 · 책임 · 현장 · 설계 담당.""" + people = (row.get("user_id"), row.get("pm_user_id"), row.get("field_lead_user_id")) + return user_id in (*people, row.get("designer_user_id")) or user_id in row.get( + "member_user_ids", [] + ) + + +def _decorate(row: dict[str, Any]) -> dict[str, Any]: + stages = row.get("workflow_state", {}).get("stages", []) + kind = row.get("project_kind") or "ROAD" + days = idle_days(row.get("updated_at")) + return { + **row, + "project_kind": kind, + "stage_total": STAGE_TOTAL.get(kind, 0), + "board": board_column(kind, stages), + "idle_days": days, + "stalled": days is not None and days > STALE_DAYS, + } + + +def _scope_where(session: dict[str, Any], company_id: int | None) -> tuple[str, tuple[Any, ...]]: + """볼 범위 — 시스템 관리자 = 고른 회사(없음 = 전체) · 회사 사람 = 자기 회사 · 그 밖 = 내 것.""" + if session["role"] == "SYSTEM_ADMIN": + return ("p.company_id = %s", (company_id,)) if company_id else ("1 = 1", ()) + own = session.get("company_id") + if company_id is not None and int(company_id) != int(own or 0): + raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.") + if own: + return "p.company_id = %s", (int(own),) + return "p.user_id = %s", (int(session["user_id"]),) + + +@router.get("/projects") +async def overview_projects( + scope: str = Query("company", pattern="^(mine|company)$"), + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(verify_session), +): + where, args = _scope_where(session, company_id) + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT {_COLUMNS} {_FROM} + WHERE {where} AND p.deleted_at IS NULL + ORDER BY p.updated_at DESC, p.created_at DESC""", + args, + ) + rows = await _project_rows(cursor, await cursor.fetchall()) + if scope == "mine": + rows = [r for r in rows if is_mine(r, int(session["user_id"]))] + return {"status": "success", "projects": [_decorate(r) for r in rows]} + + +async def _visible_project(cursor: Any, session: dict[str, Any], project_id: str) -> dict: + await cursor.execute( + f"""SELECT {_COLUMNS}, p.storage_path, + fl.name AS field_lead_name, ds.name AS designer_name {_FROM} + LEFT JOIN users fl ON fl.id = p.field_lead_user_id + LEFT JOIN users ds ON ds.id = p.designer_user_id + WHERE p.id = %s AND p.deleted_at IS NULL""", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if session["role"] != "SYSTEM_ADMIN": + own = session.get("company_id") + allowed = ( + int(row["company_id"] or 0) == int(own) + if own + else int(row["user_id"]) == int(session["user_id"]) + ) + if not allowed: + raise HTTPException(status_code=403, detail="이 프로젝트를 볼 권한이 없습니다.") + return row + + +def _route_lonlat(root: Path, limit: int = 300) -> tuple[list[list[float]], float | None]: + """설계 노선(작업본 → 초기값 → 예상노선) [lat, lon] · 연장 — 못 읽으면 ([], None).""" + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import ( + expected_route_csv_path, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, + ) + + for path in ( + planned_route_working_path(root), + planned_route_initial_path(root), + expected_route_csv_path(root), + ): + route = read_planned_route_csv(path) if path.is_file() else None + if route is None or len(route.vertices) < 2: + continue + picked = route.vertices[:: max(1, len(route.vertices) // limit)] + if picked[-1] is not route.vertices[-1]: + picked.append(route.vertices[-1]) + try: + crs = route.crs_input or project_epsg_from_prj(root) + to_wgs = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) + lonlat = [to_wgs.transform(v.x, v.y) for v in picked] + except Exception: # 좌표계 못 읽음 = 지도 없음 · 연장은 그대로 + return [], route.vertices[-1].chainage_m + points = [[round(lat, 7), round(lon, 7)] for lon, lat in lonlat] + return points, route.vertices[-1].chainage_m + return [], None + + +def _numbers(root: Path | None, length_m: float | None, stations: int) -> list[dict[str, Any]]: + """핵심 숫자 — 있는 것만(연장 · 측점 · 구조물).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + + out: list[dict[str, Any]] = [] + if length_m: + out.append({"key": "length", "value": round(float(length_m), 1), "unit": "m"}) + if stations: + out.append({"key": "stations", "value": stations}) + if root is not None: + count = len(load_structures(str(root))[1]) + if count: + out.append({"key": "structures", "value": count}) + return out + + +def project_outputs(kind: str, stages: list[dict[str, Any]]) -> list[dict[str, str]]: + """산출물 — 인쇄 · 내려받기 화면(b09-print)에서 지금 받을 수 있는 것만(임도설계). + + 도면 = 횡단(3) 마침 · 수량 = 수량(5) 마침 · 내역 = 원가(6) 들어감. + """ + if kind != "ROAD": + return [] + state = {int(s["stage_no"]): s["state"] for s in stages} + out = [] + if state.get(3) == "COMPLETE": + out.append({"key": "drawings", "label": "도면 PDF"}) + if state.get(5) == "COMPLETE": + out.append({"key": "quantity", "label": "수량 xlsx"}) + if state.get(6, "NOT_STARTED") != "NOT_STARTED": + out.append({"key": "cost", "label": "내역 xlsx"}) + return out + + +def _link(kind: str, row: dict[str, Any], note: str | None = None) -> dict[str, Any]: + return { + "kind": kind, + "project_id": row["id"], + "name": row["name"], + "project_kind": row.get("project_kind") or "PLAN", + "note": note, + } + + +async def _links(cursor: Any, row: dict[str, Any]) -> list[dict[str, Any]]: + """연결된 것 — 원본 · 이 프로젝트에서 나온 것 · 연결한 예정노선(원본과 같으면 한 번만).""" + links: list[dict[str, Any]] = [] + source = row.get("source_project_id") + if source: + await cursor.execute( + "SELECT id, name, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL", + (source,), + ) + links += [_link("source", r) for r in await cursor.fetchall()] + await cursor.execute( + """SELECT id, name, project_kind FROM projects + WHERE source_project_id = %s AND deleted_at IS NULL ORDER BY created_at""", + (row["id"],), + ) + links += [_link("derived", r) for r in await cursor.fetchall()] + await cursor.execute( + """SELECT p.id, p.name, a.year FROM plan_route_assignees a + JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL + WHERE a.linked_project_id = %s ORDER BY a.year""", + (row["id"],), + ) + for r in await cursor.fetchall(): + if r["id"] == source: + links[0]["note"] = f"{r['year']} 년" + else: + links.append(_link("plan", r, f"{r['year']} 년")) + return links + + +#: 최근 변경 — 감사 기록 중 사람 글로 보일 것 +_AUDIT_ACTIONS = ("PROJECT_CREATE", "PROJECT_UPDATE") + + +async def _changes(cursor: Any, project_id: str, stages: list[dict[str, Any]]) -> list[dict]: + """최근 변경 — 감사 기록(만듦 · 정보 고침) + 단계 마친 때 · 새것부터 8 개.""" + await cursor.execute( + """SELECT l.action, DATE_FORMAT(l.timestamp, '%%Y-%%m-%%d %%H:%%i:%%s') AS at, + u.name AS who + FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id + WHERE l.resource_type = 'project' AND l.resource_ref = %s + AND l.action IN (%s, %s) + ORDER BY l.timestamp DESC LIMIT 8""", + (project_id, *_AUDIT_ACTIONS), + ) + items: list[dict[str, Any]] = [dict(r) for r in await cursor.fetchall()] + items += [ + { + "at": s["completed_at"].replace("T", " "), + "who": None, + "action": "STAGE_COMPLETE", + "stage_no": s["stage_no"], + } + for s in stages + if s.get("state") == "COMPLETE" and s.get("completed_at") + ] + return sorted(items, key=lambda item: item["at"], reverse=True)[:8] + + +@router.get("/projects/{project_id}") +async def overview_project_detail( + project_id: str, session: dict[str, Any] = Depends(verify_session) +): + from common_util.common_util_storage import resolve_stored_project_path + + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + raw = await _visible_project(cursor, session, project_id) + row = _decorate((await _project_rows(cursor, [raw]))[0]) + await cursor.execute( + """SELECT COUNT(*) AS n FROM cross_sections WHERE route_id = ( + SELECT id FROM routes WHERE project_id = %s + ORDER BY computed_at DESC, id DESC LIMIT 1)""", + (project_id,), + ) + stations = int((await cursor.fetchone())["n"]) + links = await _links(cursor, row) + changes = await _changes(cursor, project_id, row["workflow_state"]["stages"]) + try: + root = Path(resolve_stored_project_path(raw["storage_path"])) + except (ValueError, TypeError, AttributeError): + root = None + line, length = _route_lonlat(root) if root else ([], None) + people = { + "pm": row["manager_name"] if row.get("pm_user_id") else None, + "field_lead": row.pop("field_lead_name", None), + "designer": row.pop("designer_name", None), + } + row.pop("storage_path", None) + return { + "status": "success", + "project": row, + "people": people, + "route": {"points": line} if line else None, + "numbers": _numbers(root, length, stations), + "links": links, + "outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]), + "changes": changes, + } 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_ProjectList.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts new file mode 100644 index 000000000..a7e4bf638 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList.ts + * 대시보드 「프로젝트」 세부 페이지(요구 369~374 · PLAN 548-1) — 툴바(찾기 · 종류 칩 · 담당 · 보기 · + * 카드/표/칸반 · [+ 새 프로젝트]) + 고른 보기. 고른 보기는 계정 취향(`aislo:pref:`)으로 기억. + * 칸반은 9 개 넘을 때만 · 칸은 서버가 단계 상태로 나눔(끌기 없음). + * ========================================================================== */ + +import "./B01_Dashboard_UI_Style_Projects.css"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements_base"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { fetchOverviewProjects, type OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; +import { KINDS } from "./B01_Dashboard_UI_ProjectList_Parts"; +import { cardGrid, kanbanBoard, projectRows } from "./B01_Dashboard_UI_ProjectList_Views"; + +/** 등록부 ctx(546) 가운데 이 페이지가 쓰는 것 */ +export interface ProjectViewCtx { + me: DashboardUser; + companyId: number | null; + go: (view: string, params?: Record) => void; +} + +type ViewMode = "card" | "table" | "kanban"; +type Scope = "mine" | "company"; + +/** 칸반이 열리는 수 — 넘으면 [칸반] 단추 */ +export const KANBAN_MIN = 10; +const PREF_VIEW = "aislo:pref:b01.projects.view"; +const PREF_SCOPE = "aislo:pref:b01.projects.scope"; + +interface ListState { + scope: Scope; + view: ViewMode; + kind: string; + manager: string; + query: string; + projects: OverviewProject[]; +} + +/** 거르기 — 종류 · 담당 · 찾기(이름 · 지역) */ +export function filterProjects( + projects: OverviewProject[], + kind: string, + manager: string, + query: string, +): OverviewProject[] { + const q = query.trim().toLowerCase(); + return projects.filter( + (p) => + (!kind || (p.project_kind ?? "ROAD") === kind) && + (!manager || p.manager_name === manager) && + (!q || `${p.name} ${p.region ?? ""}`.toLowerCase().includes(q)), + ); +} + +function segment( + items: { key: T; label: string }[], + active: T, + onPick: (key: T) => void, +): HTMLElement { + const seg = el("span", { className: "b01-proj-seg", attrs: { role: "group" } }); + for (const item of items) { + const button = el("button", { + className: item.key === active ? "is-on" : "", + text: item.label, + attrs: { type: "button", "data-key": item.key }, + }); + button.addEventListener("click", () => onPick(item.key)); + seg.append(button); + } + return seg; +} + +function chip(label: string, on: boolean, onClick?: () => void, soon = false): HTMLElement { + const button = el("button", { + className: `b01-proj-chip${on ? " is-on" : ""}${soon ? " is-soon" : ""}`, + text: label, + attrs: { type: "button" }, + }); + button.addEventListener("click", () => (soon ? showToast(label) : onClick?.())); + return button; +} + +/** 「프로젝트」 페이지 — 등록부 mount */ +export function mountProjectList(host: HTMLElement, ctx: ProjectViewCtx): () => void { + let alive = true; + const state: ListState = { + scope: readByKey(PREF_SCOPE) === "mine" ? "mine" : "company", + view: (readByKey(PREF_VIEW) as ViewMode | null) ?? "card", + kind: "", + manager: "", + query: "", + projects: [], + }; + const root = el("div", { className: "b01-proj" }); + const head = el("div", { className: "b01-proj-head" }); + const tools = el("div", { className: "b01-proj-tools" }); + const body = el("div", { className: "b01-proj-body" }); + root.append(head, tools, body); + host.replaceChildren(root); + + const search = el("input", { + className: "b01-proj-search", + attrs: { type: "search", placeholder: "이름 · 지역 찾기" }, + }); + search.addEventListener("input", () => { + state.query = search.value; + paintBody(); + }); + + const open = (project: OverviewProject): void => ctx.go("project", { id: project.id }); + + function paintHead(): void { + const kanbanOk = state.projects.length >= KANBAN_MIN; + const views: { key: ViewMode; label: string }[] = [ + { key: "card", label: "카드" }, + { key: "table", label: "표" }, + ...(kanbanOk ? [{ key: "kanban" as ViewMode, label: "칸반" }] : []), + ]; + head.replaceChildren( + el("h2", { text: "프로젝트" }), + el("span", { className: "b01-proj-count", text: String(state.projects.length) }), + el("span", { className: "b01-proj-sp" }), + segment(views, shownView(), (key) => { + state.view = key; + writeByKey(PREF_VIEW, key); + paintHead(); + paintBody(); + }), + createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }), + ); + } + + /** 기억한 보기가 칸반인데 9 개 이하 = 카드 */ + const shownView = (): ViewMode => + state.view === "kanban" && state.projects.length < KANBAN_MIN ? "card" : state.view; + + function paintTools(): void { + const count = (kind: string): number => + state.projects.filter((p) => (p.project_kind ?? "ROAD") === kind).length; + const chips = [ + chip(`전체 ${state.projects.length}`, !state.kind, () => pickKind("")), + ...KINDS.map((k) => + "soon" in k + ? chip(`${k.label()} · 준비 중`, false, undefined, true) + : chip(`${k.label()} ${count(k.key)}`, state.kind === k.key, () => pickKind(k.key)), + ), + ]; + const managers = [...new Set(state.projects.map((p) => p.manager_name).filter(Boolean))]; + const select = el("select", { className: "b01-proj-select", attrs: { "aria-label": "담당" } }); + select.append(new Option("담당 : 전체", ""), ...managers.map((m) => new Option(m!, m!))); + select.value = state.manager; + select.addEventListener("change", () => { + state.manager = select.value; + paintBody(); + }); + const scope = segment( + [ + { key: "mine" as Scope, label: "내 것" }, + { key: "company" as Scope, label: "회사 전체" }, + ], + state.scope, + (key) => { + state.scope = key; + writeByKey(PREF_SCOPE, key); + void load(); + }, + ); + tools.replaceChildren( + search, + ...chips, + el("span", { className: "b01-proj-sp" }), + select, + scope, + ); + } + + function pickKind(kind: string): void { + state.kind = kind; + paintTools(); + paintBody(); + } + + function paintBody(): void { + const shown = filterProjects(state.projects, state.kind, state.manager, state.query); + if (!shown.length) { + body.replaceChildren( + el("p", { + className: "b01-proj-empty", + text: state.projects.length + ? "조건에 맞는 프로젝트가 없습니다." + : "아직 프로젝트가 없습니다. [+ 새 프로젝트]로 시작합니다.", + }), + ); + return; + } + const view = shownView(); + body.replaceChildren( + view === "table" + ? projectRows(shown, ctx.me, open) + : view === "kanban" + ? kanbanBoard(shown, open) + : cardGrid(shown, open), + ); + } + + async function load(): Promise { + body.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" })); + try { + const projects = await fetchOverviewProjects(state.scope, ctx.companyId); + if (!alive) return; + state.projects = projects; + } catch (error) { + if (!alive) return; + state.projects = []; + showToast(error instanceof Error ? error.message : String(error), "error"); + } + paintHead(); + paintTools(); + paintBody(); + } + + paintHead(); + paintTools(); + void load(); + return () => { + alive = false; + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts new file mode 100644 index 000000000..82bdf42a3 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Parts.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList_Parts.ts + * 프로젝트 목록 · 상세 공용 조각(PLAN 548) — 종류 배지 · 단계 글 · 진행 막대 · 썸네일 선 · 칸반 칸 이름. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements_base"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { groupFlowOf } from "../A00_Common/group_workflow"; +import type { RoutePath } from "@config/config_frontend"; +import type { BoardColumn, OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { L } from "./B01_Dashboard_UI_Common"; + +/** 종류 — 칩 · 배지 차례 · 사방설계 = 준비 중 */ +export const KINDS = [ + { key: "ROAD", label: () => L("DE_New_Road") }, + { key: "PLAN", label: () => L("DE_New_Plan") }, + { key: "STABILITY", label: () => L("DE_New_Stability") }, + { key: "EROSION", label: () => L("DE_New_Erosion"), soon: true }, +] as const; + +export const kindLabel = (kind?: string | null): string => + (KINDS.find((k) => k.key === (kind ?? "ROAD")) ?? KINDS[0]).label(); + +export function kindBadge(kind?: string | null): HTMLElement { + const key = (kind ?? "ROAD").toLowerCase(); + return el("span", { className: `b01-proj-badge b01-proj-badge--${key}`, text: kindLabel(kind) }); +} + +export const BOARD_COLUMNS: { key: BoardColumn; label: string }[] = [ + { key: "start", label: "시작 전" }, + { key: "design", label: "설계 중" }, + { key: "review", label: "검토" }, + { key: "confirm", label: "확정 대기" }, + { key: "done", label: "완료" }, +]; + +export const boardLabel = (key: BoardColumn): string => + BOARD_COLUMNS.find((c) => c.key === key)?.label ?? key; + +/** 종류별 단계 이름 · 주소 */ +export function stageFlow(kind?: string | null): { + steps: string[]; + routes: readonly RoutePath[]; +} { + const group = groupFlowOf(kind); + return group + ? { steps: group.steps, routes: group.routes } + : { steps: workflowSteps(), routes: WORKFLOW_STEP_ROUTES }; +} + +/** 지금 단계 번호(0 부터) · 마친 단계 수 */ +export function stageProgress(project: OverviewProject): { current: number; done: number } { + const stages = project.workflow_state?.stages ?? []; + return { + current: project.workflow_state?.current_stage ?? 0, + done: stages.filter((s) => s.state === "COMPLETE").length, + }; +} + +/** 「횡단설계 4/7」 — 시작 전 = 0 · 완료 = 전체 */ +export function stageText(project: OverviewProject): string { + const { steps } = stageFlow(project.project_kind); + const total = project.stage_total || steps.length; + if (!total) return L("DE_New_Soon"); + const { current } = stageProgress(project); + if (project.board === "done") return `완료 ${total}/${total}`; + const n = project.board === "start" ? 0 : current + 1; + return `${steps[current] ?? ""} ${n}/${total}`; +} + +export function progressBar(project: OverviewProject): HTMLElement { + const total = project.stage_total || 1; + const bar = el("span", { className: "b01-proj-bar" }); + const fill = el("i"); + fill.style.width = `${Math.round((stageProgress(project).done / total) * 100)}%`; + bar.append(fill); + return bar; +} + +/** 단계 점 — 마침 · 지금 · 남음 */ +export function stageDots(project: OverviewProject): HTMLElement { + const total = project.stage_total; + const { current } = stageProgress(project); + const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state])); + const row = el("span", { className: "b01-proj-dots" }); + for (let i = 0; i < total; i += 1) { + const state = states.get(i); + const cls = + state === "COMPLETE" + ? "is-done" + : state === "STALE" || state === "FAILED" + ? "is-stale" + : i === current && project.board !== "start" + ? "is-cur" + : ""; + row.append(el("i", { className: cls })); + } + return row; +} + +/** 단계 화면으로 — 종류별 주소 */ +export function openStage(project: OverviewProject, index: number): void { + const { routes } = stageFlow(project.project_kind); + const route = routes[Math.max(0, Math.min(index, routes.length - 1))]; + if (route) void goToWorkflowStage(project.id, route); +} + +/** 썸네일 선 — 줄인 좌표(아무 평면 좌표) 를 칸에 맞춰 SVG 한 줄 · 없으면 빈 판 */ +export function thumbSvg(points: [number, number][] | null | undefined, height = 56): SVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", `0 0 200 ${height}`); + svg.classList.add("b01-proj-thumb"); + if (!points || points.length < 2) return svg; + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const span = Math.max(x1 - x0, y1 - y0) || 1; + const pad = 8; + const sx = (200 - 2 * pad) / span; + const sy = (height - 2 * pad) / span; + const scale = Math.min(sx, sy); + const ox = (200 - (x1 - x0) * scale) / 2; + const oy = (height - (y1 - y0) * scale) / 2; + const d = points + .map( + ([x, y], i) => + `${i ? "L" : "M"}${(ox + (x - x0) * scale).toFixed(1)} ${(oy + (y1 - y) * scale).toFixed(1)}`, + ) + .join(" "); + const path = document.createElementNS(ns, "path"); + path.setAttribute("d", d); + svg.append(path); + return svg; +} + +/** 「오늘」 · 「어제」 · 「n 일 전」 · 그 밖 날짜 */ +export function ago(project: OverviewProject): string { + const days = project.idle_days; + if (days === null || days === undefined) return "-"; + if (days <= 0) return "오늘"; + if (days === 1) return "어제"; + if (days < 7) return `${days} 일 전`; + return (project.updated_at ?? "").slice(0, 10); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts new file mode 100644 index 000000000..3a06dcdda --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_ProjectList_Views.ts @@ -0,0 +1,177 @@ +/* ============================================================================= + * B01_Dashboard_UI_ProjectList_Views.ts + * 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘). + * ========================================================================== */ + +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { el } from "@ui/ui_template_elements_base"; +import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import type { OverviewProject } from "./B01_Dashboard_Api_Projects"; +import { canDeleteProject, canEditProject } from "./B01_Dashboard_UI_Helper"; +import { openDeleteProjectModal, openEditProjectModal } from "./B01_Dashboard_UI_Modals"; +import { + ago, + BOARD_COLUMNS, + boardLabel, + kindBadge, + progressBar, + stageDots, + stageText, + thumbSvg, +} from "./B01_Dashboard_UI_ProjectList_Parts"; + +type Open = (project: OverviewProject) => void; + +/** 누름 = 상세 · Enter 도 같음 */ +function clickable(node: T, onOpen: () => void): T { + node.tabIndex = 0; + node.addEventListener("click", onOpen); + node.addEventListener("keydown", (event) => { + if (event.key === "Enter") onOpen(); + }); + return node; +} + +function stalledMark(project: OverviewProject): HTMLElement[] { + return project.stalled + ? [ + el("span", { + className: "b01-proj-stalled", + text: `${project.idle_days} 일 그대로`, + attrs: { title: "14 일 넘게 고친 것이 없음" }, + }), + ] + : []; +} + +function card(project: OverviewProject, onOpen: Open, compact = false): HTMLElement { + const box = el("div", { + className: `b01-proj-card b01-proj-card--${(project.project_kind ?? "ROAD").toLowerCase()}`, + attrs: { "data-project": project.id }, + }); + const info = el("div", { + className: "b01-proj-card__body", + children: [ + el("b", { className: "b01-proj-card__name", text: project.name }), + el("div", { + className: "b01-proj-card__line", + children: [ + kindBadge(project.project_kind), + el("span", { className: "b01-proj-mini", text: stageText(project) }), + ], + }), + ...(compact ? [progressBar(project)] : [stageDots(project)]), + el("div", { + className: "b01-proj-card__meta", + children: [ + el("span", { text: project.manager_name ?? "-" }), + ...stalledMark(project), + el("span", { text: ago(project) }), + ], + }), + ], + }); + box.append(thumbSvg(project.thumb, compact ? 44 : 64), info); + return clickable(box, () => onOpen(project)); +} + +export function cardGrid(projects: OverviewProject[], onOpen: Open): HTMLElement { + return el("div", { + className: "b01-proj-cards", + children: projects.map((p) => card(p, onOpen)), + }); +} + +/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */ +function rowMenuItems(project: OverviewProject, me: DashboardUser): MapContextMenuItem[] { + const items: MapContextMenuItem[] = []; + if (canEditProject(me, project)) { + items.push(["수정", () => void openEditProjectModal(me, project)]); + } + if (canDeleteProject(me)) { + items.push("-", ["삭제", () => openDeleteProjectModal(me, project)]); + } + return items; +} + +export function projectRows( + projects: OverviewProject[], + me: DashboardUser, + onOpen: Open, +): HTMLElement { + const wrap = el("div", { className: "b01-proj-table" }); + const menu = createMapContextMenu("b01-proj-menu"); + wrap.append(menu.element); + const head = ["이름", "종류", "지역", "진행", "상태", "담당", "수정일", ""]; + const table = el("table"); + const thead = el("tr", { children: head.map((h) => el("th", { text: h })) }); + table.append(thead); + for (const project of projects) { + const more = el("button", { + className: "b01-proj-more", + text: "⋯", + attrs: { type: "button", "aria-label": "더 보기" }, + }); + const items = rowMenuItems(project, me); + if (!items.length) more.hidden = true; + more.addEventListener("click", (event) => { + event.stopPropagation(); + const box = wrap.getBoundingClientRect(); + const at = more.getBoundingClientRect(); + menu.open(at.right - box.left - 120, at.bottom - box.top, items); + }); + const row = el("tr", { + className: "b01-proj-row", + attrs: { "data-project": project.id }, + children: [ + el("td", { children: [el("b", { text: project.name }), ...stalledMark(project)] }), + el("td", { children: [kindBadge(project.project_kind)] }), + el("td", { text: project.region ?? "-" }), + el("td", { + className: "b01-proj-prog", + children: [progressBar(project), el("span", { text: stageText(project) })], + }), + el("td", { text: boardLabel(project.board) }), + el("td", { text: project.manager_name ?? "-" }), + el("td", { text: (project.updated_at ?? "").slice(0, 10) || "-" }), + el("td", { children: [more] }), + ], + }); + table.append(clickable(row, () => onOpen(project))); + } + wrap.append(table); + return wrap; +} + +/** 칸반 — 칸 차례 고정 · 완료 칸은 접혀 시작(머리 누름 = 펼침) */ +export function kanbanBoard(projects: OverviewProject[], onOpen: Open): HTMLElement { + const board = el("div", { className: "b01-proj-kanban" }); + for (const column of BOARD_COLUMNS) { + const items = projects.filter((p) => p.board === column.key); + const col = el("section", { + className: "b01-proj-col", + attrs: { "data-board": column.key }, + }); + const title = el("h4", { + children: [ + el("span", { text: column.label }), + el("span", { className: "b01-proj-mini", text: String(items.length) }), + ], + }); + const list = el("div", { + className: "b01-proj-col__list", + children: items.map((p) => card(p, onOpen, true)), + }); + if (column.key === "done") { + col.classList.add("is-folded"); + title.append(el("span", { className: "b01-proj-fold", text: "펼치기" })); + title.addEventListener("click", () => { + const folded = col.classList.toggle("is-folded"); + title.lastElementChild!.textContent = folded ? "펼치기" : "접기"; + }); + } + col.append(title, list); + board.append(col); + } + return board; +} 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_Projects.css b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css new file mode 100644 index 000000000..da6a7e607 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Projects.css @@ -0,0 +1,351 @@ +/* ============================================================================= + * B01_Dashboard_UI_Style_Projects.css + * 프로젝트 목록 · 상세(PLAN 548) — 색 · 간격은 대시보드 셸 변수(--b01-*) · 없으면 공용 테마 값. + * ========================================================================== */ + +.b01-proj, +.b01-pd { + --pj-accent: var(--b01-accent, var(--color-primary, #1f7a4d)); + --pj-accent-soft: var(--b01-accent-soft, color-mix(in srgb, var(--pj-accent) 12%, transparent)); + --pj-surface: var(--b01-surface, var(--color-surface, #fff)); + --pj-sunken: var(--b01-surface-sunken, var(--color-surface-sunken, #eef0f3)); + --pj-border: var(--b01-border, var(--color-border, #e6e8ec)); + --pj-text: var(--b01-text, var(--color-text, #1f2328)); + --pj-muted: var(--b01-text-muted, var(--color-text-muted, #6b7280)); + --pj-radius: var(--b01-radius, var(--radius-cards, 12px)); + --pj-gap: var(--b01-gap, var(--spacing-16, 14px)); + --pj-road: var(--b01-kind-road, var(--pj-accent)); + --pj-plan: var(--b01-kind-plan, #2f5fbf); + --pj-stab: var(--b01-kind-stab, #c26a0f); + --pj-warn: var(--b01-warn, #d97706); + --pj-danger: var(--b01-danger, var(--color-danger, #e5484d)); + color: var(--pj-text); + font-size: var(--text-body-sm, 13.5px); +} + +/* ── 머리 · 툴바 ─────────────────────────────────────────────────────────── */ +.b01-proj-head, +.b01-pd-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.b01-proj-head h2, +.b01-pd-head h2 { + margin: 0; + font-size: 19px; +} +.b01-proj-count { + color: var(--pj-muted); +} +.b01-proj-sp { + flex: 1; +} +.b01-proj-tools { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 12px; +} +.b01-proj-search { + width: 220px; + padding: 5px 10px; + border: 1px solid var(--pj-border); + border-radius: 7px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; +} +.b01-proj-select { + padding: 3px 8px; + border: 1px solid var(--pj-border); + border-radius: 14px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; +} +.b01-proj-chip { + border: 1px solid var(--pj-border); + border-radius: 14px; + padding: 2px 10px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; + cursor: pointer; +} +.b01-proj-chip.is-on { + border-color: var(--pj-accent); + color: var(--pj-accent); + background: var(--pj-accent-soft); +} +.b01-proj-chip.is-soon { + opacity: 0.5; + border-style: dashed; +} +.b01-proj-seg { + display: inline-flex; + border: 1px solid var(--pj-border); + border-radius: 8px; + overflow: hidden; +} +.b01-proj-seg button { + border: 0; + padding: 5px 11px; + background: var(--pj-surface); + color: var(--pj-text); + font: inherit; + cursor: pointer; +} +.b01-proj-seg button.is-on { + background: var(--pj-accent); + color: #fff; +} +.b01-proj-empty { + color: var(--pj-muted); + padding: 24px 0; + text-align: center; +} +.b01-proj-mini { + color: var(--pj-muted); + font-size: 12px; +} + +/* ── 배지 · 막대 · 점 ────────────────────────────────────────────────────── */ +.b01-proj-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + border-radius: 5px; + padding: 0 6px; + margin-right: 6px; + color: var(--pj-road); + background: color-mix(in srgb, var(--pj-road) 13%, transparent); +} +.b01-proj-badge--plan { + color: var(--pj-plan); + background: color-mix(in srgb, var(--pj-plan) 13%, transparent); +} +.b01-proj-badge--stability { + color: var(--pj-stab); + background: color-mix(in srgb, var(--pj-stab) 13%, transparent); +} +.b01-proj-badge--erosion { + color: var(--pj-muted); + background: var(--pj-sunken); +} +.b01-proj-bar { + display: inline-block; + width: 90px; + height: 6px; + border-radius: 4px; + background: var(--pj-sunken); + overflow: hidden; + vertical-align: middle; +} +.b01-proj-bar i { + display: block; + height: 100%; + background: var(--pj-accent); +} +.b01-proj-dots { + display: flex; + gap: 4px; + margin: 6px 0 2px; +} +.b01-proj-dots i { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pj-sunken); + border: 1px solid var(--pj-border); +} +.b01-proj-dots i.is-done { + background: var(--pj-accent); + border-color: var(--pj-accent); +} +.b01-proj-dots i.is-cur { + border: 2px solid var(--pj-accent); + background: var(--pj-surface); +} +.b01-proj-dots i.is-stale { + background: var(--pj-warn); + border-color: var(--pj-warn); +} +.b01-proj-stalled { + color: var(--pj-warn); + font-size: 11.5px; + margin-left: 6px; +} + +/* ── 카드 ────────────────────────────────────────────────────────────────── */ +.b01-proj-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: var(--pj-gap); +} +.b01-proj-card { + background: var(--pj-surface); + border: 1px solid var(--pj-border); + border-radius: var(--pj-radius); + overflow: hidden; + cursor: pointer; + transition: + box-shadow 0.15s, + border-color 0.15s; +} +.b01-proj-card:hover, +.b01-proj-card:focus-visible { + border-color: var(--pj-accent); + box-shadow: 0 2px 10px rgb(16 24 40 / 8%); + outline: none; +} +.b01-proj-thumb { + display: block; + width: 100%; + height: 64px; + background: color-mix(in srgb, var(--pj-road) 9%, var(--pj-surface)); +} +.b01-proj-col .b01-proj-thumb { + height: 44px; +} +.b01-proj-card--plan .b01-proj-thumb { + background: color-mix(in srgb, var(--pj-plan) 9%, var(--pj-surface)); +} +.b01-proj-card--stability .b01-proj-thumb { + background: color-mix(in srgb, var(--pj-stab) 9%, var(--pj-surface)); +} +.b01-proj-thumb path { + fill: none; + stroke: #c0392b; + stroke-width: 2.5; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} +.b01-proj-card--plan .b01-proj-thumb path { + stroke: var(--pj-plan); +} +.b01-proj-card--stability .b01-proj-thumb path { + stroke: var(--pj-stab); +} +.b01-proj-card__body { + padding: 8px 10px 9px; +} +.b01-proj-card__name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.b01-proj-card__line { + margin-top: 2px; +} +.b01-proj-card .b01-proj-bar { + width: 100%; + margin-top: 5px; +} +.b01-proj-card__meta { + display: flex; + justify-content: space-between; + gap: 6px; + color: var(--pj-muted); + font-size: 11.5px; + margin-top: 5px; +} + +/* ── 표 ──────────────────────────────────────────────────────────────────── */ +.b01-proj-table { + position: relative; + background: var(--pj-surface); + border: 1px solid var(--pj-border); + border-radius: var(--pj-radius); + overflow: visible; +} +.b01-proj-table table { + width: 100%; + border-collapse: collapse; +} +.b01-proj-table th, +.b01-proj-table td { + padding: 9px 12px; + text-align: left; + border-bottom: 1px solid var(--pj-border); +} +.b01-proj-table th { + color: var(--pj-muted); + font-weight: 600; + font-size: 12px; + background: var(--pj-sunken); +} +.b01-proj-row { + cursor: pointer; +} +.b01-proj-row:hover td, +.b01-proj-row:focus-visible td { + background: var(--pj-accent-soft); +} +.b01-proj-row:focus-visible { + outline: none; +} +.b01-proj-prog span:last-child { + margin-left: 8px; +} +.b01-proj-more { + border: 0; + background: none; + color: var(--pj-muted); + font-size: 16px; + cursor: pointer; + padding: 0 6px; +} +/* 삭제 = 끝 줄(수정 뒤 구분선 다음) */ +.b01-proj-menu__context-menu .ui-ctx-menu__item:last-child:not(:first-child) { + color: var(--pj-danger); +} + +/* ── 칸반 ────────────────────────────────────────────────────────────────── */ +.b01-proj-kanban { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, 0.8fr); + gap: 12px; + align-items: start; +} +.b01-proj-col { + background: var(--pj-sunken); + border-radius: var(--pj-radius); + padding: 10px; +} +.b01-proj-col h4 { + display: flex; + gap: 6px; + align-items: center; + margin: 0 0 8px; + font-size: 13px; +} +.b01-proj-col__list { + display: grid; + gap: 8px; +} +.b01-proj-col[data-board="done"] h4 { + cursor: pointer; +} +.b01-proj-col[data-board="done"] .b01-proj-card { + opacity: 0.75; +} +.b01-proj-col.is-folded .b01-proj-col__list { + display: none; +} +.b01-proj-fold { + margin-left: auto; + color: var(--pj-muted); + font-size: 11.5px; + font-weight: 400; +} + +@media (max-width: 900px) { + .b01-proj-kanban { + grid-template-columns: 1fr 1fr; + } +} 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/main.py b/main.py index f39967346..b70813046 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,7 @@ from A07_Register.A07_Register_Router import router as a07_register_router from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router +from B01_Dashboard.B01_Dashboard_Router_Overview import router as b01_overview_router from B01_Dashboard.B01_Dashboard_Router_Home import router as b01_home_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Repository import ProjectNotFoundError @@ -465,6 +466,7 @@ app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) app.include_router(b01_dashboard_router) +app.include_router(b01_overview_router) app.include_router(b01_home_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] 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; +}