Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2
This commit is contained in:
@@ -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<T>(path: string): Promise<T> {
|
||||
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<OverviewProject[]> {
|
||||
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<ProjectDetail> {
|
||||
return getJson<ProjectDetail>(`/dashboard/overview/projects/${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string, (s: DashboardState) => 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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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, string> = {}): 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<string, HTMLAnchorElement>();
|
||||
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<HTMLElement>(".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<HTMLButtonElement>(".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();
|
||||
}
|
||||
@@ -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<void> {
|
||||
export async function renderB01Dashboard(root: HTMLElement): Promise<MountedPage | void> {
|
||||
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<void> {
|
||||
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<typeof L>[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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>) => 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<T extends string>(
|
||||
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<void> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<T extends HTMLElement>(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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<MountedPage> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>) => 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<Cleanup>;
|
||||
}
|
||||
|
||||
export const DEFAULT_VIEW = "home";
|
||||
|
||||
const ALL: readonly ViewRole[] = ["USER", "ADMIN", "FOREST", "SYSTEM_ADMIN"];
|
||||
const legacy =
|
||||
(key: LegacyKey) =>
|
||||
(host: HTMLElement, ctx: ViewCtx): Promise<void> =>
|
||||
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)!;
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 가 없음")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* =============================================================================
|
||||
* ui_template_icons.ts
|
||||
* 선 아이콘 한 묶음 — Tabler Icons(https://tabler.io/icons) 에서 필요한 것만 옮김 · npm 묶음 안 들임.
|
||||
* 쓰기 = `icon("home")` → <span class="ui-icon"><svg …/></span> · 색 = 글 색(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.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 아이콘 이름 → <path> 묶음(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) => `<path d="${d}"/>`)
|
||||
.join("");
|
||||
span.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
|
||||
return span;
|
||||
}
|
||||
Reference in New Issue
Block a user