Compare commits
32
Commits
0ab85ec3b3
...
aeb6bc498f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb6bc498f | ||
|
|
85ae6319e4 | ||
|
|
8721bd7b18 | ||
|
|
41973e9874 | ||
|
|
c914702a84 | ||
|
|
efb9b9ca67 | ||
|
|
66f5858e54 | ||
|
|
43d0ccb63f | ||
|
|
bbedceeae1 | ||
|
|
adc64ddcf3 | ||
|
|
a924f466bf | ||
|
|
7e34152de8 | ||
|
|
bed7957a65 | ||
|
|
c5518a8783 | ||
|
|
729f4c75b8 | ||
|
|
70786cef9e | ||
|
|
9826538c74 | ||
|
|
13b417120c | ||
|
|
95a2bed75b | ||
|
|
bd1a080afc | ||
|
|
2002affddf | ||
|
|
6b0d509077 | ||
|
|
5aaf7f4e65 | ||
|
|
93e99872e4 | ||
|
|
070791854c | ||
|
|
3274d4b107 | ||
|
|
bc1c8b44e8 | ||
|
|
7190bf2cfe | ||
|
|
3c69fb96ca | ||
|
|
ffa5d250e8 | ||
|
|
c6d93ee880 | ||
|
|
8b1b286e04 |
@@ -495,6 +495,14 @@ export async function fetchAuditLogs(): Promise<AuditLog[]> {
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/** 시스템 로그 한 쪽 — 최신순 · total = 전체 줄 수 */
|
||||
export async function fetchAuditLogPage(
|
||||
page: number,
|
||||
limit = 100,
|
||||
): Promise<{ items: AuditLog[]; total: number }> {
|
||||
return request(`/dashboard/admin/audit-logs?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function fetchSystemResources(days = 30): Promise<ResourceData> {
|
||||
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_Api_Home.ts
|
||||
* 대시보드 홈 API(PLAN 547) — GET /dashboard/home · /dashboard/projects/summary
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 프로젝트 카드 요약(547-1 · `B01_Dashboard_Summary.py`) — 값이 없으면 칸이 빠짐 */
|
||||
export interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
region?: string | null;
|
||||
company_id?: number | null;
|
||||
stage_index: number;
|
||||
stage_total: number;
|
||||
stage_name: string;
|
||||
done: boolean;
|
||||
updated_at?: string | null;
|
||||
stale_days?: number | null;
|
||||
owner_user_id?: number | null;
|
||||
owner_names: string[];
|
||||
/** 노선 중심선 0~1(y 아래로) · 없으면 null */
|
||||
thumb: [number, number][] | null;
|
||||
metrics: { length_m?: number; stations?: number; structures?: number };
|
||||
}
|
||||
|
||||
export interface PlanTodo {
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
year: number;
|
||||
linked_project_id: string | null;
|
||||
}
|
||||
|
||||
export interface HomeData {
|
||||
role: "SYSTEM_ADMIN" | "FOREST" | "ADMIN" | "USER";
|
||||
/** 진행 중(완료 아님) · 최근 수정 순 · 썸네일은 앞 8 개만 */
|
||||
projects: ProjectSummary[];
|
||||
/** 홈 범위 프로젝트 전체(완료 포함) — 0 = 처음 */
|
||||
project_total: number;
|
||||
todos: {
|
||||
new_plans: PlanTodo[];
|
||||
updated_plans: PlanTodo[];
|
||||
join_requests?: number;
|
||||
expiring_temp: { batch_id: string; name: string; expires_at: string; days_left: number }[];
|
||||
temp_count: number;
|
||||
};
|
||||
company?: { id: number; name: string | null; member_count: number | null };
|
||||
forest?: {
|
||||
plan_status: { draft: number; analyzed: number; confirmed: number };
|
||||
assign_waiting: number;
|
||||
};
|
||||
system?: {
|
||||
companies: number;
|
||||
users: number;
|
||||
company_names: Record<string, string>;
|
||||
server: { cpu: number | null; memory: number | null; disk: number | null };
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
|
||||
if (!response.ok) throw new Error(`홈 자료를 불러오지 못했습니다 (${response.status})`);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const fetchHome = (): Promise<HomeData> => getJson<HomeData>("/dashboard/home");
|
||||
|
||||
/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */
|
||||
export async function fetchProjectSummaries(ids?: string[]): Promise<ProjectSummary[]> {
|
||||
const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : "";
|
||||
return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`))
|
||||
.projects;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
/** 썸네일 선(0~1 칸 · y 아래로) — 547-1 카드 요약 */
|
||||
thumb?: number[][] | null;
|
||||
metrics?: { length_m?: number; stations?: number; structures?: number };
|
||||
}
|
||||
|
||||
/** 지도 선 — 위경도 [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;
|
||||
stage_no?: number;
|
||||
}
|
||||
|
||||
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,205 @@
|
||||
"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from common_util.common_util_auth import is_forest_email, verify_session
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
from .B01_Dashboard_Repository import get_system_resources
|
||||
from .B01_Dashboard_Summary import project_summaries
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"])
|
||||
|
||||
SUMMARY_LIMIT = 200
|
||||
# 홈 카드 썸네일은 앞 8 개만(9 넘음 꼴은 그림 대신 숫자 · 그래프)
|
||||
HOME_THUMBS = 8
|
||||
EXPIRE_SOON_DAYS = 7
|
||||
|
||||
|
||||
async def visible_project_ids(session: dict[str, Any]) -> list[str]:
|
||||
"""보이는 프로젝트(최근 수정 순) — 시스템 관리자 = 전부 · 회사 = 회사 전체 · 없으면 내 것."""
|
||||
where, args = "p.deleted_at IS NULL", ()
|
||||
if session["role"] != "SYSTEM_ADMIN":
|
||||
if session.get("company_id"):
|
||||
where, args = where + " AND p.company_id = %s", (int(session["company_id"]),)
|
||||
else:
|
||||
where, args = where + " AND p.user_id = %s", (int(session["user_id"]),)
|
||||
return await _ids(where, args)
|
||||
|
||||
|
||||
async def _ids(where: str, args: tuple) -> list[str]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
f"SELECT p.id FROM projects p WHERE {where} "
|
||||
"ORDER BY p.updated_at DESC, p.created_at DESC",
|
||||
args,
|
||||
)
|
||||
return [str(r["id"]) for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
@router.get("/projects/summary")
|
||||
async def projects_summary(
|
||||
ids: str | None = Query(None, max_length=8000, description="쉼표로 이은 프로젝트 id"),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
"""카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) · 안 보이는 id 는 빠짐."""
|
||||
visible = await visible_project_ids(session)
|
||||
if ids:
|
||||
allowed = set(visible)
|
||||
wanted = dict.fromkeys(pid.strip() for pid in ids.split(","))
|
||||
visible = [pid for pid in wanted if pid in allowed]
|
||||
return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])}
|
||||
|
||||
|
||||
def home_role(session: dict[str, Any]) -> str:
|
||||
"""홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖)."""
|
||||
if session["role"] in ("SYSTEM_ADMIN", "ADMIN"):
|
||||
return session["role"]
|
||||
return "FOREST" if is_forest_email(session.get("email")) else "USER"
|
||||
|
||||
|
||||
async def home_project_ids(session: dict[str, Any], role: str) -> list[str]:
|
||||
"""홈 프로젝트 — 시스템 관리자 = 전부 · 회사 관리자 = 회사 · 그 밖 = 내가 만든 · 참여한 것."""
|
||||
if role in ("SYSTEM_ADMIN", "ADMIN") or not session.get("company_id"):
|
||||
return await visible_project_ids(session)
|
||||
me = int(session["user_id"])
|
||||
return await _ids(
|
||||
"p.deleted_at IS NULL AND p.company_id = %s AND (p.user_id = %s OR EXISTS "
|
||||
"(SELECT 1 FROM project_members m WHERE m.project_id = p.id AND m.user_id = %s))",
|
||||
(int(session["company_id"]), me, me),
|
||||
)
|
||||
|
||||
|
||||
async def _one(cursor: Any, sql: str, args: tuple = ()) -> dict[str, Any]:
|
||||
await cursor.execute(sql, args)
|
||||
return await cursor.fetchone() or {}
|
||||
|
||||
|
||||
async def _todos(cursor: Any, session: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
"""처리할 일 · 보관함 — 받은 예정노선(새로 · 업데이트) · 가입 요청 · 곧 만료 보관 파일."""
|
||||
from D01_PlanRegister.D01_PlanRegister_Router import assigned_plan_routes
|
||||
|
||||
company_id = session.get("company_id")
|
||||
plans = (await assigned_plan_routes(session))["routes"] if company_id else []
|
||||
pick = ("project_id", "project_name", "year", "linked_project_id")
|
||||
todos: dict[str, Any] = {
|
||||
"new_plans": [
|
||||
{k: p[k] for k in pick}
|
||||
for p in plans
|
||||
if not p["linked_project_id"] and p["confirmed_at"]
|
||||
],
|
||||
"updated_plans": [{k: p[k] for k in pick} for p in plans if p["update_available"]],
|
||||
}
|
||||
if role == "SYSTEM_ADMIN" or (role == "ADMIN" and company_id):
|
||||
where = "" if role == "SYSTEM_ADMIN" else " AND company_id = %s"
|
||||
row = await _one(
|
||||
cursor,
|
||||
f"SELECT COUNT(*) AS n FROM join_requests WHERE status = 'PENDING'{where}",
|
||||
() if role == "SYSTEM_ADMIN" else (int(company_id),),
|
||||
)
|
||||
todos["join_requests"] = int(row.get("n") or 0)
|
||||
await cursor.execute(
|
||||
"""SELECT id, name, status, expires_at FROM temp_upload_batches
|
||||
WHERE user_id = %s AND status <> 'linked'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY expires_at IS NULL, expires_at""",
|
||||
(int(session["user_id"]),),
|
||||
)
|
||||
batches = list(await cursor.fetchall())
|
||||
soon = datetime.now() + timedelta(days=EXPIRE_SOON_DAYS)
|
||||
todos["expiring_temp"] = [
|
||||
{
|
||||
"batch_id": str(b["id"]),
|
||||
"name": b["name"],
|
||||
"expires_at": b["expires_at"].isoformat(),
|
||||
"days_left": max(0, (b["expires_at"] - datetime.now()).days),
|
||||
}
|
||||
for b in batches
|
||||
if b["expires_at"] and b["expires_at"] <= soon
|
||||
]
|
||||
todos["temp_count"] = len(batches)
|
||||
return todos
|
||||
|
||||
|
||||
async def _company(cursor: Any, company_id: int) -> dict[str, Any]:
|
||||
row = await _one(
|
||||
cursor,
|
||||
"""SELECT c.name, (SELECT COUNT(*) FROM users u
|
||||
WHERE u.company_id = c.id AND u.deleted_at IS NULL) AS member_count
|
||||
FROM companies c WHERE c.id = %s""",
|
||||
(company_id,),
|
||||
)
|
||||
return {"id": company_id, "name": row.get("name"), "member_count": row.get("member_count")}
|
||||
|
||||
|
||||
async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
|
||||
"""예정노선 연도 상태별 수(분석 전 · 분석됨 · 확정) · 업체 지정 대기(확정 · 업체 없음)."""
|
||||
await cursor.execute(
|
||||
"""SELECT y.route_json IS NOT NULL AS analyzed, y.confirmed_at IS NOT NULL AS confirmed,
|
||||
EXISTS (SELECT 1 FROM plan_route_assignees a
|
||||
WHERE a.project_id = y.project_id AND a.year = y.year) AS assigned
|
||||
FROM plan_route_years y
|
||||
JOIN projects p ON p.id = y.project_id AND p.deleted_at IS NULL
|
||||
WHERE p.company_id = %s""",
|
||||
(company_id,),
|
||||
)
|
||||
status = {"draft": 0, "analyzed": 0, "confirmed": 0}
|
||||
waiting = 0
|
||||
for r in await cursor.fetchall():
|
||||
key = "confirmed" if r["confirmed"] else "analyzed" if r["analyzed"] else "draft"
|
||||
status[key] += 1
|
||||
waiting += bool(r["confirmed"] and not r["assigned"])
|
||||
return {"plan_status": status, "assign_waiting": waiting}
|
||||
|
||||
|
||||
async def _system(cursor: Any) -> dict[str, Any]:
|
||||
row = await _one(
|
||||
cursor,
|
||||
"""SELECT (SELECT COUNT(*) FROM companies WHERE deleted_at IS NULL) AS companies,
|
||||
(SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS users""",
|
||||
)
|
||||
await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL")
|
||||
names = {int(r["id"]): r["name"] for r in await cursor.fetchall()}
|
||||
current = (await get_system_resources(days=1))["current"]
|
||||
server = {k: current.get(f"{k}_usage_percent") for k in ("cpu", "memory", "disk")}
|
||||
return {
|
||||
"companies": int(row.get("companies") or 0),
|
||||
"users": int(row.get("users") or 0),
|
||||
"company_names": names,
|
||||
"server": server,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/home")
|
||||
async def dashboard_home(session: dict[str, Any] = Depends(verify_session)):
|
||||
"""역할별 홈 — 진행 중 프로젝트 카드(최근 수정 순) · 처리할 일 · 회사 · 산림청 · 시스템 요약.
|
||||
|
||||
단계 분포 · 팀원별 · 종류별 · 회사별 수는 화면이 `projects` 로 셈(같은 목록 한 벌).
|
||||
"""
|
||||
role = home_role(session)
|
||||
ids = await home_project_ids(session, role)
|
||||
cards = [s for s in await project_summaries(ids[:SUMMARY_LIMIT]) if not s["done"]]
|
||||
for card in cards[HOME_THUMBS:]:
|
||||
card["thumb"] = None
|
||||
company_id = session.get("company_id")
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
body: dict[str, Any] = {
|
||||
"status": "success",
|
||||
"role": role,
|
||||
"projects": cards,
|
||||
"project_total": len(ids),
|
||||
"todos": await _todos(cursor, session, role),
|
||||
}
|
||||
if role == "ADMIN" and company_id:
|
||||
body["company"] = await _company(cursor, int(company_id))
|
||||
if role == "FOREST" and company_id:
|
||||
body["forest"] = await _forest(cursor, int(company_id))
|
||||
if role == "SYSTEM_ADMIN":
|
||||
body["system"] = await _system(cursor)
|
||||
return body
|
||||
@@ -0,0 +1,327 @@
|
||||
"""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
|
||||
from .B01_Dashboard_Summary import project_summaries
|
||||
|
||||
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"]))]
|
||||
summaries = {s["id"]: s for s in await project_summaries([str(r["id"]) for r in rows])}
|
||||
return {
|
||||
"status": "success",
|
||||
"projects": [_with_summary(_decorate(r), summaries.get(str(r["id"]))) for r in rows],
|
||||
}
|
||||
|
||||
|
||||
def _with_summary(row: dict[str, Any], summary: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""카드 요약(547-1) 의 썸네일 선 · 핵심 숫자를 붙임."""
|
||||
summary = summary or {}
|
||||
return {**row, "thumb": summary.get("thumb"), "metrics": summary.get("metrics", {})}
|
||||
|
||||
|
||||
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(metrics: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""핵심 숫자 — 카드 요약 값 가운데 있는 것만(연장 · 측점 · 구조물)."""
|
||||
keys = (
|
||||
("length_m", "length", "m"),
|
||||
("stations", "stations", None),
|
||||
("structures", "structures", None),
|
||||
)
|
||||
return [
|
||||
{"key": key, "value": metrics[name], **({"unit": unit} if unit else {})}
|
||||
for name, key, unit in keys
|
||||
if metrics.get(name)
|
||||
]
|
||||
|
||||
|
||||
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])
|
||||
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 = _route_lonlat(root)[0] if root else []
|
||||
summary = (await project_summaries([project_id]) or [{}])[0]
|
||||
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(summary.get("metrics", {})),
|
||||
"links": links,
|
||||
"outputs": project_outputs(row["project_kind"], row["workflow_state"]["stages"]),
|
||||
"changes": changes,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""프로젝트 카드 요약 (PLAN 547-1) — 홈 · 목록 · 상세 카드가 같이 씀.
|
||||
|
||||
단계 = 대시보드 표 단계 막대와 같은 값(`workflow_state.current_stage` · 종류별 단계 목록).
|
||||
썸네일 = 최신 노선 경로 GeoJSON(B05 정본) 중심선을 100 점 안쪽으로 줄여 0~1 칸에 맞춤
|
||||
(가로세로 비 유지 · 가운데 · y 는 화면 방향 = 아래로 커짐). 값이 없으면 칸을 뺌.
|
||||
쿼리 = 프로젝트 · 단계 · 참여자 · 노선 · 측점 다섯 번(개수와 무관) + 노선 · 구조물 파일 읽기.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.M02_Template_Migrate import project_root
|
||||
|
||||
from .B01_Dashboard_Repository import get_workflow_states_for_projects
|
||||
|
||||
THUMB_POINTS = 100
|
||||
|
||||
# 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도
|
||||
STAGE_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"),
|
||||
"PLAN": ("기준 지정", "노선 분석", "확정 · 내보내기"),
|
||||
"STABILITY": ("지반 조건", "2D 해석", "3D 해석", "보고서", "암반 해석", "광역 위험"),
|
||||
}
|
||||
|
||||
|
||||
def stage_names(kind: str | None) -> tuple[str, ...]:
|
||||
return STAGE_NAMES.get(kind or "ROAD", STAGE_NAMES["ROAD"])
|
||||
|
||||
|
||||
def thumb_line(points: list[list[float]], limit: int = THUMB_POINTS) -> list[list[float]] | None:
|
||||
"""중심선 → limit 점 안쪽 · 0~1 칸(가로세로 비 유지 · y 아래로). 두 점 못 되면 None."""
|
||||
if len(points) < 2:
|
||||
return None
|
||||
if len(points) > limit:
|
||||
last = len(points) - 1
|
||||
points = [points[round(i * last / (limit - 1))] for i in range(limit)]
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
dx, dy = max(xs) - min(xs), max(ys) - min(ys)
|
||||
span = max(dx, dy) or 1.0
|
||||
ox, oy = (1 - dx / span) / 2, (1 - dy / span) / 2
|
||||
return [
|
||||
[round(ox + (x - min(xs)) / span, 4), round(1 - oy - (y - min(ys)) / span, 4)]
|
||||
for x, y in zip(xs, ys, strict=True)
|
||||
]
|
||||
|
||||
|
||||
def _route_points(root: Path | None, route_data_path: str | None) -> list[list[float]]:
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
|
||||
|
||||
if root is None or not route_data_path:
|
||||
return []
|
||||
try:
|
||||
return [[x, y] for x, y, _ in _load_route_polyline(root, route_data_path)]
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _structure_count(root: Path | None) -> int | None:
|
||||
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path
|
||||
|
||||
if root is None:
|
||||
return None
|
||||
try:
|
||||
with open(structures_file_path(str(root)), encoding="utf-8") as handle:
|
||||
items = json.load(handle).get("structures")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
return len(items) if isinstance(items, list) else None
|
||||
|
||||
|
||||
def _marks(values: list[Any]) -> str:
|
||||
return ", ".join(["%s"] * len(values))
|
||||
|
||||
|
||||
async def project_summaries(project_ids: list[str]) -> list[dict[str, Any]]:
|
||||
"""프로젝트 여럿 → 카드 요약(넣은 차례 · 지운 · 없는 프로젝트는 빠짐)."""
|
||||
ids = list(dict.fromkeys(str(pid) for pid in project_ids))
|
||||
if not ids:
|
||||
return []
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
f"""SELECT p.id, p.name, p.project_kind, p.region, p.updated_at, p.storage_path,
|
||||
p.company_id, p.user_id, u.name AS owner_name
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id IN ({_marks(ids)}) AND p.deleted_at IS NULL""",
|
||||
tuple(ids),
|
||||
)
|
||||
rows = {str(r["id"]): r for r in await cursor.fetchall()}
|
||||
states = await get_workflow_states_for_projects(cursor, list(rows))
|
||||
await cursor.execute(
|
||||
f"""SELECT m.project_id, u.name FROM project_members m
|
||||
JOIN users u ON u.id = m.user_id AND u.deleted_at IS NULL
|
||||
WHERE m.project_id IN ({_marks(ids)}) ORDER BY u.name""",
|
||||
tuple(ids),
|
||||
)
|
||||
members: dict[str, list[str]] = {}
|
||||
for r in await cursor.fetchall():
|
||||
members.setdefault(str(r["project_id"]), []).append(r["name"])
|
||||
# 최신 노선 = B05 get_latest_route 와 같은 차례(computed_at · id 내림)
|
||||
await cursor.execute(
|
||||
f"""SELECT id, project_id, total_length_m, route_data_path FROM routes
|
||||
WHERE project_id IN ({_marks(ids)})
|
||||
ORDER BY computed_at DESC, id DESC""",
|
||||
tuple(ids),
|
||||
)
|
||||
routes: dict[str, dict[str, Any]] = {}
|
||||
for r in await cursor.fetchall():
|
||||
routes.setdefault(str(r["project_id"]), r)
|
||||
stations: dict[int, int] = {}
|
||||
route_ids = [int(r["id"]) for r in routes.values()]
|
||||
if route_ids:
|
||||
await cursor.execute(
|
||||
f"""SELECT route_id, COUNT(*) AS n FROM cross_sections
|
||||
WHERE route_id IN ({_marks(route_ids)}) GROUP BY route_id""",
|
||||
tuple(route_ids),
|
||||
)
|
||||
stations = {int(r["route_id"]): int(r["n"]) for r in await cursor.fetchall()}
|
||||
|
||||
now = datetime.now()
|
||||
result = []
|
||||
for pid in ids:
|
||||
row = rows.get(pid)
|
||||
if row is None:
|
||||
continue
|
||||
state = states.get(pid, {"current_stage": 0, "stages": []})
|
||||
names = stage_names(row.get("project_kind"))
|
||||
index = min(int(state["current_stage"]), len(names) - 1)
|
||||
stages = state["stages"]
|
||||
root = project_root(row.get("storage_path"))
|
||||
route = routes.get(pid)
|
||||
metrics: dict[str, Any] = {}
|
||||
if route and route.get("total_length_m") is not None:
|
||||
metrics["length_m"] = round(float(route["total_length_m"]), 1)
|
||||
if route and stations.get(int(route["id"])):
|
||||
metrics["stations"] = stations[int(route["id"])]
|
||||
structures = _structure_count(root)
|
||||
if structures is not None:
|
||||
metrics["structures"] = structures
|
||||
owners = [row["owner_name"]] if row.get("owner_name") else []
|
||||
owners += [name for name in members.get(pid, []) if name and name not in owners]
|
||||
updated = row.get("updated_at")
|
||||
result.append(
|
||||
{
|
||||
"id": pid,
|
||||
"name": row["name"],
|
||||
"kind": row.get("project_kind") or "ROAD",
|
||||
"region": row.get("region"),
|
||||
"company_id": row.get("company_id"),
|
||||
"stage_index": index,
|
||||
"stage_total": len(names),
|
||||
"stage_name": names[index],
|
||||
"done": bool(stages) and all(s["state"] == "COMPLETE" for s in stages),
|
||||
"updated_at": updated.isoformat() if updated else None,
|
||||
"stale_days": (now - updated).days if updated else None,
|
||||
"owner_user_id": row.get("user_id"),
|
||||
"owner_names": owners,
|
||||
"thumb": thumb_line(
|
||||
_route_points(root, route.get("route_data_path")) if route else []
|
||||
),
|
||||
"metrics": metrics,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -1,17 +1,10 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_AssignedPlans.ts
|
||||
* 대시보드 「받은 예정노선」 구역(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
|
||||
* 열 = 예정노선 · 연도 · 확정(KST) · 연결 상태(연결됨 · 미연결 · 업데이트 있음 배지) · [보기] [연결]
|
||||
* [보기] = 그 예정노선 첫 단계(D02 · 공유 회사 = GET 만) · [연결] = 모달(`B01_Dashboard_UI_PlanLink`)
|
||||
* 「받은 예정노선」 자료 읽기(요구서 345 2-2 · PLAN 528-2) — 우리 회사에 지정된 예정노선 연도.
|
||||
* 화면 = `B01_Dashboard_UI_View_Assigned.ts` · [연결] 모달 = `B01_Dashboard_UI_PlanLink`
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
|
||||
import { kstText } from "../common_util/common_util_time_kst";
|
||||
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
|
||||
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
export interface AssignedPlan {
|
||||
assignee_id: number;
|
||||
@@ -32,54 +25,3 @@ export async function fetchAssignedPlans(): Promise<AssignedPlan[]> {
|
||||
if (!response.ok) return [];
|
||||
return ((await response.json()) as { routes?: AssignedPlan[] }).routes ?? [];
|
||||
}
|
||||
|
||||
function linkState(plan: AssignedPlan): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "b01-dashboard__actions";
|
||||
cell.append(
|
||||
createTag(
|
||||
L(plan.linked_project_id ? "DE_Assigned_Linked" : "DE_Assigned_Unlinked"),
|
||||
plan.linked_project_id ? "success" : "neutral",
|
||||
),
|
||||
);
|
||||
if (plan.update_available) cell.append(createTag(L("DE_Assigned_Update"), "warning"));
|
||||
return cell;
|
||||
}
|
||||
|
||||
export function assignedPlanTable(plans: AssignedPlan[]): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("DE_New_Plan"),
|
||||
L("DE_Assigned_Year"),
|
||||
L("D04_Col_Confirmed"),
|
||||
L("DE_Assigned_State"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
plans.map((plan) => {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
const link = createButton({
|
||||
label: L("DE_Assigned_Link"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
|
||||
});
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("DE_Assigned_View"),
|
||||
variant: "ghost",
|
||||
onClick: () => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE),
|
||||
}),
|
||||
link,
|
||||
);
|
||||
return [
|
||||
text(plan.project_name),
|
||||
text(plan.year),
|
||||
text(kstText(plan.confirmed_at)),
|
||||
linkState(plan),
|
||||
actions,
|
||||
];
|
||||
}),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,163 +1,14 @@
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchCompanyAssets,
|
||||
processJoinRequest,
|
||||
setCompanyLogo,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||
import {
|
||||
openChangeRoleModal,
|
||||
openEditCompanyModal,
|
||||
openCreateCompanyModal,
|
||||
openDeleteUserModal,
|
||||
openEditUserModal,
|
||||
openFindCompanyModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import type { DashboardState } from "./B01_Dashboard_UI_Page";
|
||||
import { actionPair, table, text } from "@ui/ui_template_general_blocks";
|
||||
import { DASHBOARD_VISIBLE_ROWS, formatDate, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__actions";
|
||||
if (!state.company) {
|
||||
wrap.append(
|
||||
createTag(L("B01_Dashboard_NoCompany"), "warning"),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_CreateCompany"),
|
||||
onClick: () => openCreateCompanyModal(),
|
||||
}),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_FindCompany"),
|
||||
variant: "ghost",
|
||||
onClick: () => openFindCompanyModal(),
|
||||
}),
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
wrap.append(
|
||||
createTag(`${state.company.name} (${state.user.status})`, "success"),
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
|
||||
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
|
||||
);
|
||||
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
|
||||
if (state.user.role !== "USER" && state.company) {
|
||||
const company = state.company;
|
||||
wrap.append(
|
||||
createButton({
|
||||
label: "회사 정보 수정",
|
||||
variant: "ghost",
|
||||
onClick: () => openEditCompanyModal(company),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||
if (state.user.role !== "USER") {
|
||||
const company = state.company;
|
||||
const slot = document.createElement("div");
|
||||
void fetchCompanyAssets(company.id).then((assets) => {
|
||||
slot.append(
|
||||
createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
company.logo_asset_id ?? null,
|
||||
company.id,
|
||||
state.user,
|
||||
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
|
||||
).root,
|
||||
);
|
||||
});
|
||||
wrap.append(slot);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function memberTable(members: Member[], currentUser: DashboardUser): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
L("B01_Dashboard_Table_Name"),
|
||||
L("B01_Dashboard_Table_Position"),
|
||||
L("B01_Dashboard_Table_Department"),
|
||||
L("B01_Dashboard_Table_Role"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
members.map((member) => {
|
||||
const actionsEl = document.createElement("div");
|
||||
actionsEl.className = "b01-dashboard__actions";
|
||||
|
||||
actionsEl.append(
|
||||
createButton({
|
||||
label: L("Common_Btn_Edit"),
|
||||
variant: "ghost",
|
||||
onClick: () => openEditUserModal(currentUser, member),
|
||||
}),
|
||||
);
|
||||
|
||||
if (canChangeRole(currentUser, member)) {
|
||||
actionsEl.append(
|
||||
createButton({
|
||||
label: L("B01_Dashboard_ChangeRole"),
|
||||
variant: "ghost",
|
||||
onClick: () => openChangeRoleModal(member),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (canDeleteUser(currentUser, member)) {
|
||||
actionsEl.append(
|
||||
createButton({
|
||||
label: L("B01_Dashboard_RemoveMember"),
|
||||
variant: "danger",
|
||||
onClick: () => openDeleteUserModal(member),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
text(member.email),
|
||||
text(member.name),
|
||||
text(member.position),
|
||||
text(member.department),
|
||||
text(member.role),
|
||||
actionsEl,
|
||||
];
|
||||
}),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
|
||||
export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): HTMLElement {
|
||||
return table(
|
||||
[
|
||||
L("B01_Dashboard_Table_Email"),
|
||||
...(systemMode ? [L("B01_Dashboard_Table_Company")] : []),
|
||||
L("B01_Dashboard_Table_Requested"),
|
||||
L("B01_Dashboard_Table_Status"),
|
||||
L("B01_Dashboard_Table_Action"),
|
||||
],
|
||||
requests.map((request) => [
|
||||
text(request.user_email),
|
||||
...(systemMode ? [text(request.company_name)] : []),
|
||||
text(formatDate(request.requested_at)),
|
||||
text(request.status),
|
||||
actionPair(
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "APPROVE", systemMode),
|
||||
() => onB01_JoinRequest_Process_Click(request.id, "REJECT", systemMode),
|
||||
),
|
||||
]),
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
}
|
||||
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { DASHBOARD_VISIBLE_ROWS, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
||||
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Home.ts
|
||||
* 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜
|
||||
* 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프
|
||||
* 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음
|
||||
* 꼴 고정(자동 · 작게 · 크게) = localStorage 기억
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home";
|
||||
import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject";
|
||||
import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards";
|
||||
import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout";
|
||||
import "./B01_Dashboard_UI_Style_Home.css";
|
||||
|
||||
const FIXED_KEY = "aislo.b01.homeLayout";
|
||||
const LAYOUT_TEXT: Record<HomeLayout, string> = {
|
||||
empty: "처음 시작",
|
||||
one: "진행 중 1 개 → 큰 카드",
|
||||
few: "진행 중 2~3 개 → 중간 카드",
|
||||
list: "진행 중 4~8 개 → 한 줄 목록",
|
||||
many: "진행 중 9 개 넘음 → 요약 · 그래프",
|
||||
};
|
||||
|
||||
function readFixed(): HomeFixed {
|
||||
const saved = localStorage.getItem(FIXED_KEY);
|
||||
return saved === "small" || saved === "large" ? saved : "auto";
|
||||
}
|
||||
|
||||
function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement {
|
||||
const seg = el("div", {
|
||||
className: "b01-home__seg",
|
||||
attrs: { role: "group", "aria-label": "꼴" },
|
||||
});
|
||||
for (const [value, label] of [
|
||||
["auto", "자동"],
|
||||
["small", "작게"],
|
||||
["large", "크게"],
|
||||
] as const) {
|
||||
const b = el("button", {
|
||||
className: value === current ? "on" : "",
|
||||
text: label,
|
||||
attrs: { type: "button", "aria-pressed": String(value === current) },
|
||||
});
|
||||
b.addEventListener("click", () => onPick(value));
|
||||
seg.append(b);
|
||||
}
|
||||
return seg;
|
||||
}
|
||||
|
||||
function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement {
|
||||
return el("div", {
|
||||
className: "b01-home__todo",
|
||||
children: [
|
||||
el("span", { className: `b01-home__dot b01-home__dot--${dot}` }),
|
||||
el("span", { text }),
|
||||
createButton({ label: action, variant: "ghost", size: "sm", onClick }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 처리할 일 — 없으면 「처리할 일 없음」 */
|
||||
function todoPane(data: HomeData, go: Go): HTMLElement {
|
||||
const t = data.todos;
|
||||
const rows: HTMLElement[] = [];
|
||||
for (const p of t.new_plans)
|
||||
rows.push(
|
||||
todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
|
||||
);
|
||||
for (const p of t.updated_plans)
|
||||
rows.push(
|
||||
todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")),
|
||||
);
|
||||
if (t.join_requests)
|
||||
rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people")));
|
||||
for (const b of t.expiring_temp)
|
||||
rows.push(
|
||||
todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")),
|
||||
);
|
||||
if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" }));
|
||||
return pane("처리할 일", ...rows);
|
||||
}
|
||||
|
||||
function sidePanes(data: HomeData, go: Go): HTMLElement[] {
|
||||
const panes = [todoPane(data, go)];
|
||||
panes.push(
|
||||
pane(
|
||||
"임시 보관함",
|
||||
el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }),
|
||||
createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }),
|
||||
),
|
||||
);
|
||||
if (data.company) {
|
||||
const c = data.company;
|
||||
panes.push(
|
||||
pane(
|
||||
c.name ?? "회사",
|
||||
el("div", {
|
||||
className: "b01-home__muted",
|
||||
text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`,
|
||||
}),
|
||||
createButton({
|
||||
label: "사람 ›",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => go("people"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.forest) {
|
||||
const f = data.forest;
|
||||
panes.push(
|
||||
pane(
|
||||
"예정노선",
|
||||
el("div", {
|
||||
className: "b01-home__muted",
|
||||
text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`,
|
||||
}),
|
||||
el("div", {
|
||||
className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted",
|
||||
text: `업체 지정 대기 ${f.assign_waiting}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
return panes;
|
||||
}
|
||||
|
||||
/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */
|
||||
function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement {
|
||||
const me = ctx.me;
|
||||
const steps: [boolean, string, string, () => void][] = [
|
||||
[
|
||||
Boolean(me.position || me.department || me.phone),
|
||||
"내 정보 채우기",
|
||||
"내 정보 ›",
|
||||
() => ctx.go("me"),
|
||||
],
|
||||
[data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")],
|
||||
[false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()],
|
||||
];
|
||||
return pane(
|
||||
"처음 오셨네요 — 세 걸음",
|
||||
...steps.map(([done, text, action, onClick], i) =>
|
||||
el("div", {
|
||||
className: `b01-home__step${done ? " b01-home__step--done" : ""}`,
|
||||
children: [
|
||||
el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }),
|
||||
el("span", { text }),
|
||||
createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement {
|
||||
const cards: ProjectSummary[] = data.projects;
|
||||
const go = ctx.go;
|
||||
if (layout === "empty")
|
||||
return data.project_total
|
||||
? pane(
|
||||
"진행 중인 프로젝트 없음",
|
||||
el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }),
|
||||
)
|
||||
: firstSteps(ctx, data);
|
||||
if (layout === "one") return bigCard(cards[0], go);
|
||||
if (layout === "few") {
|
||||
const mids = el("div", {
|
||||
className: "b01-home__mids",
|
||||
children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)),
|
||||
});
|
||||
if (cards.length <= 8) return mids;
|
||||
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
|
||||
return el("div", { className: "b01-home__many", children: [mids, all] });
|
||||
}
|
||||
if (layout === "list")
|
||||
return el("div", {
|
||||
className: "b01-home__split b01-home__split--list",
|
||||
children: [
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", {
|
||||
className: "b01-home__muted",
|
||||
text: `진행 중 ${cards.length} · 최근 작업 순`,
|
||||
}),
|
||||
el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }),
|
||||
],
|
||||
}),
|
||||
el("div", { children: [memberPane(cards), stagePane(cards)] }),
|
||||
],
|
||||
});
|
||||
return manyView(data, go);
|
||||
}
|
||||
|
||||
export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void {
|
||||
let alive = true;
|
||||
let data: HomeData | null = null;
|
||||
const root = el("div", { className: "b01-home" });
|
||||
host.append(root);
|
||||
|
||||
const draw = (): void => {
|
||||
if (!data) return;
|
||||
const fixed = readFixed();
|
||||
const layout = homeLayout(data.projects.length, fixed);
|
||||
root.dataset.layout = layout;
|
||||
const head = el("div", {
|
||||
className: "b01-home__head",
|
||||
children: [
|
||||
el("h2", { text: "홈" }),
|
||||
el("span", {
|
||||
className: "b01-home__muted",
|
||||
text:
|
||||
fixed === "auto" || layout === "empty"
|
||||
? LAYOUT_TEXT[layout]
|
||||
: `진행 중 ${data.projects.length} 개 · ${fixed === "small" ? "작게" : "크게"} 고정`,
|
||||
}),
|
||||
el("span", { className: "b01-home__push" }),
|
||||
fixedSwitch(fixed, (next) => {
|
||||
if (next === "auto") localStorage.removeItem(FIXED_KEY);
|
||||
else localStorage.setItem(FIXED_KEY, next);
|
||||
draw();
|
||||
}),
|
||||
createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }),
|
||||
],
|
||||
});
|
||||
const body = el("div", {
|
||||
className: "b01-home__body",
|
||||
children: [
|
||||
el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }),
|
||||
el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }),
|
||||
],
|
||||
});
|
||||
root.replaceChildren(head, body);
|
||||
};
|
||||
|
||||
root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" }));
|
||||
fetchHome()
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
data = loaded;
|
||||
draw();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!alive) return;
|
||||
root.replaceChildren(
|
||||
el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }),
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Home_Cards.ts
|
||||
* 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8)
|
||||
* [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id })
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePath } from "@config/config_frontend";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { groupFlowOf } from "../A00_Common/group_workflow";
|
||||
import type { ProjectSummary } from "./B01_Dashboard_Api_Home";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import { isStale } from "./logic/B01_Dashboard_Home_Layout";
|
||||
|
||||
export type Go = (view: string, params?: Record<string, string>) => void;
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
const KIND_LABEL = {
|
||||
ROAD: "DE_New_Road",
|
||||
PLAN: "DE_New_Plan",
|
||||
STABILITY: "DE_New_Stability",
|
||||
EROSION: "DE_New_Erosion",
|
||||
} as const;
|
||||
|
||||
export const kindLabel = (kind: string): string =>
|
||||
L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road");
|
||||
|
||||
export const kindClass = (kind: string): string =>
|
||||
`b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`;
|
||||
|
||||
/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */
|
||||
export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement {
|
||||
const svg = document.createElementNS(SVG, "svg");
|
||||
svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`);
|
||||
svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
if (card.thumb) {
|
||||
const line = document.createElementNS(SVG, "polyline");
|
||||
line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" "));
|
||||
line.setAttribute("vector-effect", "non-scaling-stroke");
|
||||
svg.append(line);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */
|
||||
export function stagePips(card: ProjectSummary): HTMLElement {
|
||||
const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` });
|
||||
for (let i = 0; i < card.stage_total; i += 1) {
|
||||
const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : "";
|
||||
pips.append(el("i", { className: state }));
|
||||
}
|
||||
return pips;
|
||||
}
|
||||
|
||||
export const stageText = (card: ProjectSummary): string =>
|
||||
`${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`;
|
||||
|
||||
/** 「n 일째 그대로」 · 오늘 · n 일 전 */
|
||||
export function whenText(card: ProjectSummary): string {
|
||||
const days = card.stale_days ?? null;
|
||||
if (days === null) return "";
|
||||
if (isStale(card)) return `${days} 일째 그대로`;
|
||||
return days === 0 ? "오늘" : `${days} 일 전`;
|
||||
}
|
||||
|
||||
export function continueStage(card: ProjectSummary): void {
|
||||
const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES;
|
||||
void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]);
|
||||
}
|
||||
|
||||
function kindBadge(card: ProjectSummary): HTMLElement {
|
||||
return el("span", {
|
||||
className: `b01-home__badge ${kindClass(card.kind)}`,
|
||||
text: kindLabel(card.kind),
|
||||
});
|
||||
}
|
||||
|
||||
function title(card: ProjectSummary): HTMLElement {
|
||||
return el("div", {
|
||||
className: "b01-home__title",
|
||||
children: [el("b", { text: card.name }), kindBadge(card)],
|
||||
});
|
||||
}
|
||||
|
||||
function nextBox(card: ProjectSummary): HTMLElement {
|
||||
return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` });
|
||||
}
|
||||
|
||||
function whenLine(card: ProjectSummary): HTMLElement {
|
||||
const line = el("div", { className: "b01-home__muted" });
|
||||
const owner = card.owner_names[0];
|
||||
if (owner) line.append(`${owner} · `);
|
||||
line.append(
|
||||
el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }),
|
||||
);
|
||||
return line;
|
||||
}
|
||||
|
||||
const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [
|
||||
["length_m", "연장", (v) => `${v.toLocaleString()} m`],
|
||||
["stations", "측점", (v) => `${v}`],
|
||||
["structures", "구조물", (v) => `${v}`],
|
||||
];
|
||||
|
||||
/** 핵심 숫자 — 있는 값만 */
|
||||
function metricStrip(card: ProjectSummary): HTMLElement | null {
|
||||
const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map(
|
||||
([key, label, fmt]) =>
|
||||
el("div", {
|
||||
children: [
|
||||
el("b", { text: fmt(card.metrics[key] as number) }),
|
||||
el("span", { text: label }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null;
|
||||
}
|
||||
|
||||
const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement =>
|
||||
createButton({ label, size: "sm", onClick: () => continueStage(card) });
|
||||
|
||||
const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "자세히",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => go("project", { id: card.id }),
|
||||
});
|
||||
|
||||
/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */
|
||||
export function bigCard(card: ProjectSummary, go: Go): HTMLElement {
|
||||
const info = el("div", {
|
||||
className: "b01-home__big-info",
|
||||
children: [
|
||||
el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }),
|
||||
title(card),
|
||||
el("b", { className: "b01-home__stage", text: stageText(card) }),
|
||||
stagePips(card),
|
||||
nextBox(card),
|
||||
whenLine(card),
|
||||
el("div", {
|
||||
className: "b01-home__actions",
|
||||
children: [continueButton(card), detailButton(card, go)],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const top = el("div", {
|
||||
className: "b01-home__big-top",
|
||||
children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info],
|
||||
});
|
||||
const metrics = metricStrip(card);
|
||||
return el("div", {
|
||||
className: "b01-home__pane b01-home__big",
|
||||
children: metrics ? [top, metrics] : [top],
|
||||
});
|
||||
}
|
||||
|
||||
/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */
|
||||
export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement {
|
||||
const node = el("div", {
|
||||
className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`,
|
||||
children: [
|
||||
thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement,
|
||||
el("div", {
|
||||
className: "b01-home__mid-body",
|
||||
children: [
|
||||
whenLine(card),
|
||||
title(card),
|
||||
el("div", { className: "b01-home__muted", text: stageText(card) }),
|
||||
stagePips(card),
|
||||
nextBox(card),
|
||||
el("div", {
|
||||
className: "b01-home__actions",
|
||||
children: [
|
||||
continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"),
|
||||
detailButton(card, go),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */
|
||||
export function rowCard(card: ProjectSummary, go: Go): HTMLElement {
|
||||
const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" });
|
||||
open.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
continueStage(card);
|
||||
});
|
||||
const row = el("div", {
|
||||
className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`,
|
||||
attrs: { role: "button", tabindex: "0" },
|
||||
children: [
|
||||
thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement,
|
||||
el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }),
|
||||
el("div", {
|
||||
className: "b01-home__row-stage",
|
||||
children: [
|
||||
stagePips(card),
|
||||
el("div", {
|
||||
className: "b01-home__muted",
|
||||
text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
open,
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => go("project", { id: card.id }));
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") go("project", { id: card.id });
|
||||
});
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Home_Many.ts
|
||||
* 홈 4 넘음 부품(PLAN 547-2) — 팀원별 · 단계 분포(4~8) · 요약 숫자 · 막대 · 원 그래프(9 넘음)
|
||||
* 그래프 = 인라인 SVG(새 라이브러리 없음) · 색 = 셸 · 테마 변수(CSS 클래스)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { HomeData, ProjectSummary } from "./B01_Dashboard_Api_Home";
|
||||
import { kindClass, kindLabel, type Go } from "./B01_Dashboard_UI_Home_Cards";
|
||||
import {
|
||||
countBy,
|
||||
donutArcs,
|
||||
isStale,
|
||||
stageBuckets,
|
||||
topWithRest,
|
||||
} from "./logic/B01_Dashboard_Home_Layout";
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svgNode(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const node = document.createElementNS(SVG, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
||||
return node;
|
||||
}
|
||||
|
||||
export function pane(titleText: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
|
||||
const node = el("div", {
|
||||
className: "b01-home__pane",
|
||||
children: [el("h3", { className: "b01-home__pane-title", text: titleText })],
|
||||
});
|
||||
node.append(...children);
|
||||
return node;
|
||||
}
|
||||
|
||||
const ownerOf = (card: ProjectSummary): string => card.owner_names[0] ?? "담당 없음";
|
||||
|
||||
/** 팀원별 진행 중 개수 */
|
||||
export function memberPane(cards: ProjectSummary[]): HTMLElement {
|
||||
const rows = countBy(cards, ownerOf).map(({ label, count }) =>
|
||||
el("div", {
|
||||
className: "b01-home__member",
|
||||
children: [
|
||||
el("span", { className: "b01-home__avatar", text: label.slice(0, 1) }),
|
||||
el("span", { text: label }),
|
||||
el("span", { className: "b01-home__muted b01-home__push", text: `${count} 개` }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return pane("팀원별", ...rows);
|
||||
}
|
||||
|
||||
/** 단계 분포 — 시작 전 · 설계 중 · 확정 대기 · 쌓은 막대 */
|
||||
export function stagePane(cards: ProjectSummary[]): HTMLElement {
|
||||
const b = stageBuckets(cards);
|
||||
const total = Math.max(1, cards.length);
|
||||
const parts: [number, string, string][] = [
|
||||
[b.start, "start", "시작 전"],
|
||||
[b.working, "working", "설계 중"],
|
||||
[b.last, "last", "확정 대기"],
|
||||
];
|
||||
const bar = el("div", { className: "b01-home__stack" });
|
||||
for (const [n, cls, label] of parts) {
|
||||
if (!n) continue;
|
||||
const seg = el("i", {
|
||||
className: `b01-home__stack--${cls}`,
|
||||
attrs: { title: `${label} ${n}` },
|
||||
});
|
||||
seg.style.width = `${(n / total) * 100}%`;
|
||||
bar.append(seg);
|
||||
}
|
||||
const text = parts.map(([n, , label]) => `${label} ${n}`).join(" · ");
|
||||
const stale = cards.filter(isStale).length;
|
||||
const legend = el("div", { className: "b01-home__muted", text });
|
||||
const children: HTMLElement[] = [bar, legend];
|
||||
if (stale)
|
||||
children.push(
|
||||
el("div", { className: "b01-home__stale", text: `14 일 넘게 그대로 ${stale} 개` }),
|
||||
);
|
||||
return pane("단계 분포", ...children);
|
||||
}
|
||||
|
||||
function kpi(label: string, value: string, note?: string, warn = false): HTMLElement {
|
||||
return el("div", {
|
||||
className: "b01-home__kpi",
|
||||
children: [
|
||||
el("div", { className: "b01-home__muted", text: label }),
|
||||
el("b", { className: warn ? "b01-home__alert" : "", text: value }),
|
||||
...(note ? [el("span", { className: "b01-home__muted", text: note })] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 가로 막대 — 회사별(시스템 관리자) · 담당별(그 밖) · 앞 4 + 그 밖 */
|
||||
function barChart(rows: { label: string; count: number; rest?: number }[]): SVGElement {
|
||||
const shown = topWithRest(rows, 5);
|
||||
const max = Math.max(1, ...shown.map((r) => r.count));
|
||||
const rowH = 22;
|
||||
const svg = svgNode("svg", {
|
||||
class: "b01-home__bars",
|
||||
viewBox: `0 0 320 ${shown.length * rowH}`,
|
||||
width: "100%",
|
||||
preserveAspectRatio: "xMinYMin meet",
|
||||
role: "img",
|
||||
});
|
||||
shown.forEach((r, i) => {
|
||||
const y = i * rowH;
|
||||
const label = r.rest ? `그 밖 ${r.rest} 곳` : r.label;
|
||||
const width = (r.count / max) * 180;
|
||||
const text = svgNode("text", { x: 0, y: y + 14 });
|
||||
text.textContent = label.length > 9 ? `${label.slice(0, 9)}…` : label;
|
||||
const count = svgNode("text", { x: 96 + width, y: y + 14 });
|
||||
count.textContent = String(r.count);
|
||||
svg.append(
|
||||
text,
|
||||
svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3, class: r.rest ? "rest" : "" }),
|
||||
count,
|
||||
);
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** 원 그래프 — 종류별 · 가운데 = 합 */
|
||||
function kindDonut(cards: ProjectSummary[]): HTMLElement {
|
||||
const kinds = countBy(cards, (c) => c.kind);
|
||||
const arcs = donutArcs(kinds.map((k) => k.count));
|
||||
const svg = svgNode("svg", {
|
||||
class: "b01-home__donut",
|
||||
viewBox: "0 0 42 42",
|
||||
width: 96,
|
||||
height: 96,
|
||||
});
|
||||
kinds.forEach((k, i) => {
|
||||
svg.append(
|
||||
svgNode("circle", {
|
||||
cx: 21,
|
||||
cy: 21,
|
||||
r: 15.9155,
|
||||
class: kindClass(k.label),
|
||||
"stroke-dasharray": arcs[i].dash,
|
||||
"stroke-dashoffset": arcs[i].offset,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
|
||||
sum.textContent = String(cards.length);
|
||||
svg.append(sum);
|
||||
const legend = el("div", {
|
||||
className: "b01-home__legend",
|
||||
children: kinds.map((k) =>
|
||||
el("span", {
|
||||
children: [
|
||||
el("i", { className: `b01-home__swatch ${kindClass(k.label)}` }),
|
||||
`${kindLabel(k.label)} ${k.count}`,
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
return el("div", {
|
||||
className: "b01-home__donut-row",
|
||||
children: [svg as unknown as HTMLElement, legend],
|
||||
});
|
||||
}
|
||||
|
||||
/** 9 넘음 — 요약 숫자 · 회사별(담당별) 막대 · 종류 원 · 단계 분포 · [모든 프로젝트 ›] */
|
||||
export function manyView(data: HomeData, go: Go): HTMLElement {
|
||||
const cards = data.projects;
|
||||
const sys = data.system;
|
||||
const pending = data.todos.join_requests ?? 0;
|
||||
const kpis = el("div", { className: "b01-home__kpis" });
|
||||
if (sys) kpis.append(kpi("회사", String(sys.companies)), kpi("사용자", String(sys.users)));
|
||||
kpis.append(kpi("진행 중 프로젝트", String(cards.length), `전체 ${data.project_total}`));
|
||||
if (data.todos.join_requests !== undefined)
|
||||
kpis.append(
|
||||
kpi("처리 대기", String(pending), pending ? `가입 ${pending}` : undefined, pending > 0),
|
||||
);
|
||||
if (sys) {
|
||||
const s = sys.server;
|
||||
const disk = s.disk ?? 0;
|
||||
kpis.append(
|
||||
kpi(
|
||||
"서버",
|
||||
`디스크 ${Math.round(disk)}%`,
|
||||
`CPU ${Math.round(s.cpu ?? 0)} · 메모리 ${Math.round(s.memory ?? 0)}`,
|
||||
disk >= 85,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows = sys
|
||||
? countBy(cards, (c) => sys.company_names[String(c.company_id)] ?? "회사 없음")
|
||||
: countBy(cards, (c) => c.owner_names[0] ?? "담당 없음");
|
||||
const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") });
|
||||
return el("div", {
|
||||
className: "b01-home__many",
|
||||
children: [
|
||||
kpis,
|
||||
el("div", {
|
||||
className: "b01-home__split",
|
||||
children: [
|
||||
pane(sys ? "회사별 진행 중" : "담당별 진행 중", barChart(rows)),
|
||||
pane("종류", kindDonut(cards)),
|
||||
],
|
||||
}),
|
||||
stagePane(cards),
|
||||
el("div", { className: "b01-home__actions", children: [all] }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,68 +1,28 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Legacy.ts
|
||||
* 옛 한 페이지 대시보드의 구역 부품을 세부 페이지에 그대로 얹음(546-1) — 기능 빠짐 0.
|
||||
* 새 모양 페이지가 등록부(`B01_Dashboard_UI_Views.ts`) 줄을 갈아 끼우면 여기 그 구역은 지움.
|
||||
* 자료 = 열 때마다 역할별로 다시 읽음(옛 화면이 주소 바뀔 때마다 다시 읽던 것과 같음).
|
||||
* 옛 한 페이지 대시보드에서 남은 조각 — 옛 구역은 세부 페이지(549-1 · 549-2)가 다 갈아 끼움.
|
||||
* 남은 것 = 고른 회사 자리(Shell · M02) · 옛 상태 타입(Page 가 내보냄).
|
||||
* ========================================================================== */
|
||||
|
||||
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,
|
||||
import type {
|
||||
AuditLog,
|
||||
CompanyInfo,
|
||||
DashboardUser,
|
||||
JoinRequest,
|
||||
ProjectItem,
|
||||
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;
|
||||
}
|
||||
@@ -76,211 +36,3 @@ export function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]):
|
||||
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,296 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_PageKit.ts
|
||||
* 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태.
|
||||
* 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`.
|
||||
* 아이콘 중 `ui_template_icons` 에 없는 것(⋯ · + · 휴지통 · 상자)만 여기 · Tabler Icons(MIT) 선 모양.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { icon, type IconName } from "@ui/ui_template_icons";
|
||||
import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
import "./B01_Dashboard_UI_Style_Pages.css";
|
||||
|
||||
/** Tabler Icons(MIT · https://tabler.io/icons) — `ui_template_icons` 와 같은 24 × 24 · 선 2 */
|
||||
const KIT_PATHS = {
|
||||
dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0",
|
||||
plus: "M12 5l0 14|M5 12l14 0",
|
||||
pencil: "M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4|M13.5 6.5l4 4",
|
||||
upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12",
|
||||
mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6",
|
||||
inbox:
|
||||
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3",
|
||||
} as const;
|
||||
export type KitIconName = keyof typeof KIT_PATHS;
|
||||
|
||||
export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement {
|
||||
const span = document.createElement("span");
|
||||
span.className = "ui-icon";
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
const paths = KIT_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;
|
||||
}
|
||||
|
||||
function node<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const n = document.createElement(tag);
|
||||
n.className = className;
|
||||
if (text !== undefined) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 배지 — 한글 글 + 색 */
|
||||
export function badge(text: string, tone: BadgeTone = "neutral"): HTMLElement {
|
||||
const tag = createTag(text, tone);
|
||||
tag.classList.add("b01-pg-badge");
|
||||
return tag;
|
||||
}
|
||||
|
||||
/** 주 단추 — 오른쪽 위 하나 · 앞 아이콘 */
|
||||
export function primaryButton(label: string, onClick: () => void, name: KitIconName = "plus") {
|
||||
return createButton({ label, onClick, iconStart: kitIcon(name) });
|
||||
}
|
||||
|
||||
export interface PageFrame {
|
||||
root: HTMLElement;
|
||||
/** 머리 아래 몸통 */
|
||||
body: HTMLElement;
|
||||
/** 머리 오른쪽 주 단추 자리 */
|
||||
setAction: (action: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/** 페이지 제목 + 한 줄 설명 + 오른쪽 위 주 단추 */
|
||||
export function pageFrame(host: HTMLElement, title: string, desc: string): PageFrame {
|
||||
const root = node("div", "b01-pg");
|
||||
const head = node("header", "b01-pg__head");
|
||||
const text = node("div", "b01-pg__title-wrap");
|
||||
text.append(node("h2", "b01-pg__title", title), node("p", "b01-pg__desc", desc));
|
||||
const slot = node("div", "b01-pg__action");
|
||||
head.append(text, slot);
|
||||
const body = node("div", "b01-pg__body");
|
||||
root.append(head, body);
|
||||
host.append(root);
|
||||
return {
|
||||
root,
|
||||
body,
|
||||
setAction: (action) => slot.replaceChildren(...(action ? [action] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
export type Cell = string | Node;
|
||||
|
||||
export interface RowMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** 삭제 · 빼기 = 빨강 + 확인창 */
|
||||
danger?: boolean;
|
||||
/** 확인창 글(danger 일 때) */
|
||||
confirm?: string;
|
||||
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
||||
ownConfirm?: boolean;
|
||||
}
|
||||
|
||||
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
||||
export function rowMenu(items: RowMenuItem[]): HTMLElement {
|
||||
const wrap = node("div", "b01-pg-menu");
|
||||
if (!items.length) return wrap;
|
||||
const trigger = node("button", "b01-pg-menu__btn");
|
||||
trigger.type = "button";
|
||||
trigger.title = trigger.ariaLabel = "더 보기";
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.append(kitIcon("dots", 18));
|
||||
const pop = node("div", "b01-pg-menu__pop");
|
||||
pop.setAttribute("role", "menu");
|
||||
pop.hidden = true;
|
||||
const setOpen = (open: boolean): void => {
|
||||
pop.hidden = !open;
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
wrap.classList.toggle("is-open", open);
|
||||
};
|
||||
for (const item of items) {
|
||||
const b = node("button", `b01-pg-menu__item${item.danger ? " is-danger" : ""}`, item.label);
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.addEventListener("click", () => {
|
||||
setOpen(false);
|
||||
if (!item.danger || item.ownConfirm) return item.onClick();
|
||||
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
|
||||
(ok) => ok && item.onClick(),
|
||||
);
|
||||
});
|
||||
pop.append(b);
|
||||
}
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(pop.hidden === true);
|
||||
});
|
||||
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, pop);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
label: string;
|
||||
/** 칸 너비(CSS) — 없으면 나눠 가짐 */
|
||||
width?: string;
|
||||
align?: "end";
|
||||
}
|
||||
|
||||
/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */
|
||||
export function tableShell(columns: Column[]): { root: HTMLElement; tbody: HTMLElement } {
|
||||
const table = node("table", "b01-pg-table");
|
||||
const head = node("thead", "");
|
||||
const hr = node("tr", "");
|
||||
for (const col of columns) {
|
||||
const th = node("th", col.align ? "is-end" : "", col.label);
|
||||
if (col.width) th.style.width = col.width;
|
||||
hr.append(th);
|
||||
}
|
||||
head.append(hr);
|
||||
const tbody = node("tbody", "");
|
||||
table.append(head, tbody);
|
||||
const root = node("div", "b01-pg-card b01-pg-scroll");
|
||||
root.append(table);
|
||||
return { root, tbody };
|
||||
}
|
||||
|
||||
/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */
|
||||
export function tableRow(columns: Column[], cells: Cell[]): HTMLElement {
|
||||
const tr = node("tr", "");
|
||||
cells.forEach((cell, i) => {
|
||||
const col = columns[i];
|
||||
const td = node("td", col?.align ? "is-end" : col?.label === "" ? "b01-pg-menucell" : "");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** 표 — 줄 높이 40 · 세로선 없음 · 마지막 칸 = ⋯ (columns 에 label "" 로) */
|
||||
export function dataTable(columns: Column[], rows: Cell[][]): HTMLElement {
|
||||
const { root, tbody } = tableShell(columns);
|
||||
tbody.append(...rows.map((cells) => tableRow(columns, cells)));
|
||||
return root;
|
||||
}
|
||||
|
||||
export interface EmptyOptions {
|
||||
icon: IconName | KitIconName;
|
||||
text: string;
|
||||
hint?: string;
|
||||
action?: HTMLElement;
|
||||
/** 가이드 링크 — 누르면 */
|
||||
onGuide?: () => void;
|
||||
}
|
||||
|
||||
/** 빈 상태 — 선 아이콘 + 한 줄 + 주 단추 + 가이드 링크 */
|
||||
export function emptyState(opts: EmptyOptions): HTMLElement {
|
||||
const box = node("div", "b01-pg-card b01-pg-empty");
|
||||
const glyph = node("div", "b01-pg-empty__icon");
|
||||
glyph.append(
|
||||
opts.icon in KIT_PATHS
|
||||
? kitIcon(opts.icon as KitIconName, 32)
|
||||
: icon(opts.icon as IconName, 32),
|
||||
);
|
||||
box.append(glyph, node("p", "b01-pg-empty__text", opts.text));
|
||||
if (opts.hint) box.append(node("p", "b01-pg-empty__hint", opts.hint));
|
||||
if (opts.action) box.append(opts.action);
|
||||
if (opts.onGuide) {
|
||||
const link = node("button", "b01-pg-link", "사용법 보기");
|
||||
link.type = "button";
|
||||
link.addEventListener("click", opts.onGuide);
|
||||
box.append(link);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export interface TabsHandle {
|
||||
root: HTMLElement;
|
||||
setBadge: (key: string, count: number) => void;
|
||||
}
|
||||
|
||||
/** 탭 줄 — 눌러 바꿈 · 탭 오른쪽 수 배지(0 이면 숨김) */
|
||||
export function tabBar(
|
||||
tabs: { key: string; label: string }[],
|
||||
initial: string,
|
||||
onChange: (key: string) => void,
|
||||
): TabsHandle {
|
||||
const root = node("div", "b01-pg-tabs");
|
||||
root.setAttribute("role", "tablist");
|
||||
const badges = new Map<string, HTMLElement>();
|
||||
const buttons = new Map<string, HTMLButtonElement>();
|
||||
const select = (key: string): void => {
|
||||
for (const [k, b] of buttons) {
|
||||
b.classList.toggle("is-active", k === key);
|
||||
b.setAttribute("aria-selected", String(k === key));
|
||||
}
|
||||
};
|
||||
for (const tab of tabs) {
|
||||
const b = node("button", "b01-pg-tabs__tab");
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "tab");
|
||||
b.dataset.tab = tab.key;
|
||||
const count = node("span", "b01-pg-tabs__count");
|
||||
count.hidden = true;
|
||||
b.append(node("span", "", tab.label), count);
|
||||
b.addEventListener("click", () => {
|
||||
select(tab.key);
|
||||
onChange(tab.key);
|
||||
});
|
||||
buttons.set(tab.key, b);
|
||||
badges.set(tab.key, count);
|
||||
root.append(b);
|
||||
}
|
||||
select(initial);
|
||||
return {
|
||||
root,
|
||||
setBadge: (key, n) => {
|
||||
const c = badges.get(key);
|
||||
if (!c) return;
|
||||
c.textContent = String(n);
|
||||
c.hidden = n <= 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 설정 카드 한 줄(Linear 꼴) — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */
|
||||
export function settingRow(title: string, desc: string, right: Node): HTMLElement {
|
||||
const row = node("div", "b01-pg-row");
|
||||
const left = node("div", "b01-pg-row__text");
|
||||
left.append(node("strong", "", title));
|
||||
if (desc) left.append(node("span", "b01-pg-row__desc", desc));
|
||||
const side = node("div", "b01-pg-row__side");
|
||||
side.append(right);
|
||||
row.append(left, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 값 글 — 없으면 「-」 */
|
||||
export const valueText = (v: string | number | null | undefined): HTMLElement =>
|
||||
node("span", "b01-pg-row__value", v === null || v === undefined || v === "" ? "-" : String(v));
|
||||
|
||||
/** 줄 첫 칸 — 이름 + (작은 글) 보조 */
|
||||
export function nameCell(name: string, sub?: string | null): HTMLElement {
|
||||
const cell = node("div", "b01-pg-name");
|
||||
cell.append(node("span", "b01-pg-name__main", name));
|
||||
if (sub) cell.append(node("span", "b01-pg-name__sub", sub));
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** 카드 묶음 — 설정 줄을 둥근 상자 한 개에 */
|
||||
export function settingCard(rows: HTMLElement[]): HTMLElement {
|
||||
const card = node("div", "b01-pg-card b01-pg-settings");
|
||||
card.append(...rows);
|
||||
return card;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Profile.ts
|
||||
* 내 정보 카드(549-2) — 기본정보(이름 · 직급 · 부서 · 전화 · 이메일 · 서명) · 비밀번호 변경.
|
||||
* 모양 = 설정 줄(제목 · 설명 · 오른쪽 조작) · 저장 길은 옛 폼과 같음(`updateUserProfile` · `changePassword`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { createButton, createInputField } from "@ui/ui_template_elements";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import {
|
||||
changePassword,
|
||||
fetchCompanyAssets,
|
||||
@@ -8,26 +14,43 @@ import {
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
import { L, runRequest } from "./B01_Dashboard_UI_Common";
|
||||
import { settingCard, settingRow, valueText } from "./B01_Dashboard_UI_PageKit";
|
||||
import { el } from "./B01_Dashboard_UI_View_Parts";
|
||||
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
// 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
|
||||
const fields = buildUserFields(user, { self: true });
|
||||
const grid = fields.grid;
|
||||
// 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때
|
||||
// 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시).
|
||||
const signatureSlot = document.createElement("div");
|
||||
function input(value: string, type = "text", label = ""): HTMLInputElement {
|
||||
const box = el("input", "b01-sys-input");
|
||||
box.type = type;
|
||||
box.value = value;
|
||||
if (label) box.ariaLabel = label;
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 입력 줄 아래에 오류 글 — 비우면 지움 */
|
||||
function fieldError(row: HTMLElement, message?: string): void {
|
||||
row.querySelector(".b01-sys-error")?.remove();
|
||||
if (message) row.querySelector(".b01-pg-row__text")?.append(el("span", "b01-sys-error", message));
|
||||
}
|
||||
|
||||
export function buildProfileCard(user: DashboardUser): HTMLElement {
|
||||
const name = input(user.name, "text", "이름");
|
||||
const position = input(user.position ?? "", "text", "직급");
|
||||
const department = input(user.department ?? "", "text", "부서");
|
||||
const phone = input(user.phone ?? "", "tel", "전화");
|
||||
const nameRow = settingRow("이름", "도면 표제란 · 보고서에 들어갑니다.", name);
|
||||
// 본인 서명 — 도면 표제란이 이 사람 자리를 채울 때 그대로 실림(회사 소속일 때만)
|
||||
const signature = el("div", "b01-sys-assetslot");
|
||||
if (user.company_id) {
|
||||
void fetchCompanyAssets(user.company_id).then((assets) => {
|
||||
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id);
|
||||
signatureSlot.append(
|
||||
const owned = assets.find((a) => a.kind === "SIGNATURE" && a.user_id === user.id);
|
||||
signature.append(
|
||||
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, {
|
||||
owner: { id: user.id, name: user.name },
|
||||
onChange: async (assetId) => {
|
||||
if (assetId === null) return;
|
||||
const picked = assets.find((asset) => asset.id === assetId);
|
||||
const picked = assets.find((a) => a.id === assetId);
|
||||
if (picked)
|
||||
await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id });
|
||||
},
|
||||
@@ -38,73 +61,72 @@ export function buildProfileForm(user: DashboardUser): HTMLElement {
|
||||
const save = createButton({
|
||||
label: L("B01_Dashboard_SaveProfile"),
|
||||
onClick: async function onB01_Profile_Save_Click() {
|
||||
if (!fields.validate()) return;
|
||||
await runRequest(() => updateUserProfile(fields.values()));
|
||||
fieldError(nameRow);
|
||||
if (!name.value.trim()) return fieldError(nameRow, L("Common_Msg_RequiredField"));
|
||||
await runRequest(() =>
|
||||
updateUserProfile({
|
||||
name: name.value.trim(),
|
||||
position: position.value.trim() || null,
|
||||
department: department.value.trim() || null,
|
||||
phone: phone.value.trim() || null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const wrap = document.createElement("div");
|
||||
wrap.append(grid, signatureSlot, save);
|
||||
return wrap;
|
||||
const rows = [
|
||||
nameRow,
|
||||
settingRow("직급", "", position),
|
||||
settingRow("부서", "", department),
|
||||
settingRow("전화", "", phone),
|
||||
settingRow("이메일", "로그인에 쓰는 계정이라 바꿀 수 없습니다.", valueText(user.email)),
|
||||
];
|
||||
if (user.company_id)
|
||||
rows.push(settingRow("서명", "보고서 서명란 · 도면 결재란에 들어갑니다.", signature));
|
||||
rows.push(settingRow("", "", save));
|
||||
return settingCard(rows);
|
||||
}
|
||||
|
||||
export function buildSecurityForm(): HTMLElement {
|
||||
const currentPassword = createInputField({
|
||||
label: L("B01_Account_Field_CurrentPw"),
|
||||
type: "password",
|
||||
required: true,
|
||||
});
|
||||
const newPassword = createInputField({
|
||||
label: L("B01_Account_Field_NewPw"),
|
||||
type: "password",
|
||||
required: true,
|
||||
});
|
||||
const confirmPassword = createInputField({
|
||||
label: L("B01_Account_Field_ConfirmPw"),
|
||||
type: "password",
|
||||
required: true,
|
||||
});
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__form-grid";
|
||||
grid.append(currentPassword.root, newPassword.root, confirmPassword.root);
|
||||
/** 비밀번호 — [변경] 을 누르면 입력 줄이 펼쳐짐 */
|
||||
export function buildPasswordCard(): HTMLElement {
|
||||
const current = input("", "password", "현재 비밀번호");
|
||||
const next = input("", "password", "새 비밀번호");
|
||||
const confirm = input("", "password", "새 비밀번호 확인");
|
||||
const rows = [
|
||||
settingRow("현재 비밀번호", "", current),
|
||||
settingRow("새 비밀번호", `${PASSWORD_MIN_LENGTH} 자 이상`, next),
|
||||
settingRow("새 비밀번호 확인", "", confirm),
|
||||
];
|
||||
const form = el("div");
|
||||
form.hidden = true;
|
||||
const save = createButton({
|
||||
label: L("B01_Account_Save_Password"),
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Password_Save_Click() {
|
||||
currentPassword.setError();
|
||||
newPassword.setError();
|
||||
confirmPassword.setError();
|
||||
|
||||
const currentValue = currentPassword.input.value;
|
||||
const nextValue = newPassword.input.value;
|
||||
const confirmValue = confirmPassword.input.value;
|
||||
if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) {
|
||||
currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined);
|
||||
newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined);
|
||||
confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined);
|
||||
return;
|
||||
}
|
||||
if (nextValue.length < PASSWORD_MIN_LENGTH) {
|
||||
newPassword.setError(L("B01_Account_Error_PwLength"));
|
||||
return;
|
||||
}
|
||||
if (nextValue !== confirmValue) {
|
||||
confirmPassword.setError(L("B01_Account_Error_PwMismatch"));
|
||||
return;
|
||||
}
|
||||
for (const r of rows) fieldError(r);
|
||||
const required = L("Common_Msg_RequiredField");
|
||||
if (isBlank(current.value)) return fieldError(rows[0], required);
|
||||
if (isBlank(next.value)) return fieldError(rows[1], required);
|
||||
if (isBlank(confirm.value)) return fieldError(rows[2], required);
|
||||
if (next.value.length < PASSWORD_MIN_LENGTH)
|
||||
return fieldError(rows[1], L("B01_Account_Error_PwLength"));
|
||||
if (next.value !== confirm.value)
|
||||
return fieldError(rows[2], L("B01_Account_Error_PwMismatch"));
|
||||
await runRequest(() =>
|
||||
changePassword({
|
||||
current_password: currentValue,
|
||||
new_password: nextValue,
|
||||
new_password_confirm: confirmValue,
|
||||
current_password: current.value,
|
||||
new_password: next.value,
|
||||
new_password_confirm: confirm.value,
|
||||
logout_all: false,
|
||||
}),
|
||||
);
|
||||
currentPassword.input.value = "";
|
||||
newPassword.input.value = "";
|
||||
confirmPassword.input.value = "";
|
||||
current.value = next.value = confirm.value = "";
|
||||
form.hidden = true;
|
||||
},
|
||||
});
|
||||
const wrap = document.createElement("div");
|
||||
wrap.append(grid, save);
|
||||
return wrap;
|
||||
form.append(...rows, settingRow("", "", save));
|
||||
const toggle = createButton({
|
||||
label: "변경",
|
||||
variant: "ghost",
|
||||
onClick: () => (form.hidden = !form.hidden),
|
||||
});
|
||||
return settingCard([settingRow("비밀번호", "주기적으로 바꾸면 안전합니다.", toggle), form]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_ProjectDetail.ts
|
||||
* 프로젝트 상세(요구 369~374 · PLAN 548-2) — 주소 `view=project&id=`.
|
||||
* 머리(이름 · 종류 · 지역 · 설계속도 · [⋯] · [작업 열기 — 지금 단계 ›]) · 단계 진행(누름 = 그 단계 화면) ·
|
||||
* 지도(노선 · 시점 · 종점) + 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경 — 값이 있는 칸만.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B01_Dashboard_UI_Style_Projects.css";
|
||||
import type { RoutePath } from "@config/config_frontend";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { el } from "@ui/ui_template_elements_base";
|
||||
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
|
||||
import { currentQuery } from "../A00_Common/router";
|
||||
import {
|
||||
fetchProjectDetail,
|
||||
type ProjectChange,
|
||||
type ProjectDetail,
|
||||
type ProjectLink,
|
||||
} from "./B01_Dashboard_Api_Projects";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import { routeMap } from "./B01_Dashboard_UI_ProjectDetail_Map";
|
||||
import type { ProjectViewCtx } from "./B01_Dashboard_UI_ProjectList";
|
||||
import {
|
||||
kindBadge,
|
||||
openStage,
|
||||
stageFlow,
|
||||
stageProgress,
|
||||
} from "./B01_Dashboard_UI_ProjectList_Parts";
|
||||
import { openMenuAt, rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views";
|
||||
|
||||
/** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */
|
||||
const PRINT_ROUTE = "b09-print" as RoutePath;
|
||||
|
||||
const ROAD_TYPE: Record<string, Parameters<typeof L>[0]> = {
|
||||
main: "B02_Proj_RoadType_Main",
|
||||
fire: "B02_Proj_RoadType_Fire",
|
||||
work: "B02_Proj_RoadType_Work",
|
||||
};
|
||||
const roadType = (value?: string | null): string | null =>
|
||||
value ? (ROAD_TYPE[value] ? L(ROAD_TYPE[value]) : value) : null;
|
||||
|
||||
const NUMBER_LABEL: Record<string, string> = {
|
||||
length: "연장",
|
||||
stations: "측점",
|
||||
structures: "구조물",
|
||||
};
|
||||
const LINK_LABEL: Record<ProjectLink["kind"], string> = {
|
||||
source: "원본",
|
||||
derived: "이어 만든 것",
|
||||
plan: "받은 예정노선",
|
||||
};
|
||||
|
||||
function pane(title: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement {
|
||||
const box = el("section", { className: "b01-pd-pane", children: [el("h3", { text: title })] });
|
||||
box.append(...children);
|
||||
return box;
|
||||
}
|
||||
|
||||
function steps(detail: ProjectDetail): HTMLElement {
|
||||
const project = detail.project;
|
||||
const { steps: names } = stageFlow(project.project_kind);
|
||||
const { current } = stageProgress(project);
|
||||
const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state]));
|
||||
const row = el("div", { className: "b01-pd-steps" });
|
||||
row.style.setProperty("--n", String(names.length));
|
||||
names.forEach((name, index) => {
|
||||
const state = states.get(index);
|
||||
const done = state === "COMPLETE";
|
||||
const cls = done
|
||||
? "is-done"
|
||||
: state === "STALE" || state === "FAILED"
|
||||
? "is-stale"
|
||||
: index === current && project.board !== "start"
|
||||
? "is-cur"
|
||||
: "";
|
||||
const step = el("button", {
|
||||
className: `b01-pd-step ${cls}`,
|
||||
attrs: { type: "button", title: `${name} 화면 열기` },
|
||||
children: [el("span", { text: done ? "✓" : String(index + 1) }), el("b", { text: name })],
|
||||
});
|
||||
step.addEventListener("click", () => openStage(project, index));
|
||||
row.append(step);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function numbers(detail: ProjectDetail): HTMLElement | null {
|
||||
if (!detail.numbers.length) return null;
|
||||
return el("div", {
|
||||
className: "b01-pd-kpis",
|
||||
children: detail.numbers.map((n) =>
|
||||
el("div", {
|
||||
className: "b01-pd-kpi",
|
||||
children: [
|
||||
el("b", { text: `${n.value.toLocaleString("ko-KR")}${n.unit ? ` ${n.unit}` : ""}` }),
|
||||
el("span", { text: NUMBER_LABEL[n.key] ?? n.key }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function infoPane(detail: ProjectDetail): HTMLElement {
|
||||
const p = detail.project;
|
||||
const year = p.project_year
|
||||
? `${p.project_year}${p.project_year_end && p.project_year_end !== p.project_year ? ` ~ ${p.project_year_end}` : ""}`
|
||||
: null;
|
||||
const people = [
|
||||
...new Set([p.manager_name, detail.people.field_lead, detail.people.designer].filter(Boolean)),
|
||||
].join(" · ");
|
||||
const rows: [string, string | null | undefined][] = [
|
||||
["사업 연도", year],
|
||||
["지역", p.region],
|
||||
["임도 종류", roadType(p.road_type)],
|
||||
["설계속도", p.design_speed_kph ? `${p.design_speed_kph} km/h` : null],
|
||||
["시행청", p.client_org],
|
||||
["사업 번호", p.project_number],
|
||||
["담당", people],
|
||||
["만든 날", (p.created_at ?? "").slice(0, 10)],
|
||||
];
|
||||
const kv = el("div", { className: "b01-pd-kv" });
|
||||
for (const [label, value] of rows) {
|
||||
if (value) kv.append(el("span", { text: label }), el("b", { text: value }));
|
||||
}
|
||||
return pane("기본 정보", kv);
|
||||
}
|
||||
|
||||
function linkPane(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement | null {
|
||||
if (!detail.links.length) return null;
|
||||
return pane(
|
||||
"연결된 것",
|
||||
...detail.links.map((link) =>
|
||||
el("div", {
|
||||
className: "b01-pd-link",
|
||||
children: [
|
||||
kindBadge(link.project_kind),
|
||||
el("span", { text: `${LINK_LABEL[link.kind]} · ${link.name}` }),
|
||||
...(link.note ? [el("span", { className: "b01-proj-mini", text: link.note })] : []),
|
||||
el("span", { className: "b01-proj-sp" }),
|
||||
createButton({
|
||||
label: "보기",
|
||||
variant: "ghost",
|
||||
onClick: () => ctx.go("project", { id: link.project_id }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function outputPane(detail: ProjectDetail): HTMLElement | null {
|
||||
if (!detail.outputs.length) return null;
|
||||
return pane(
|
||||
"산출물 내려받기",
|
||||
...detail.outputs.map((out) =>
|
||||
el("div", {
|
||||
className: "b01-pd-link",
|
||||
attrs: { "data-output": out.key },
|
||||
children: [
|
||||
el("span", { text: out.label }),
|
||||
el("span", { className: "b01-proj-sp" }),
|
||||
createButton({
|
||||
label: "받기 화면",
|
||||
variant: "ghost",
|
||||
onClick: () => void goToWorkflowStage(detail.project.id, PRINT_ROUTE),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function changeText(change: ProjectChange, detail: ProjectDetail): string {
|
||||
const what =
|
||||
change.action === "STAGE_COMPLETE"
|
||||
? `${stageFlow(detail.project.project_kind).steps[change.stage_no ?? 0] ?? ""} 마침`
|
||||
: change.action === "PROJECT_CREATE"
|
||||
? "만듦"
|
||||
: "정보 고침";
|
||||
return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function changePane(detail: ProjectDetail): HTMLElement | null {
|
||||
if (!detail.changes.length) return null;
|
||||
return pane(
|
||||
"최근 변경",
|
||||
el("div", {
|
||||
className: "b01-pd-timeline",
|
||||
children: detail.changes.map((c) => el("div", { text: changeText(c, detail) })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function head(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
|
||||
const p = detail.project;
|
||||
const { steps: names } = stageFlow(p.project_kind);
|
||||
const { current } = stageProgress(p);
|
||||
const sub = [
|
||||
p.region,
|
||||
roadType(p.road_type),
|
||||
p.design_speed_kph && `설계속도 ${p.design_speed_kph} km/h`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const bar = el("div", { className: "b01-pd-head" });
|
||||
const menu = createMapContextMenu("b01-proj-menu");
|
||||
const items = rowMenuItems(p, ctx.me);
|
||||
const more = el("button", {
|
||||
className: "b01-proj-more",
|
||||
text: "⋯",
|
||||
attrs: { type: "button", "aria-label": "더 보기" },
|
||||
});
|
||||
more.hidden = !items.length;
|
||||
more.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
openMenuAt(menu, bar, more, items);
|
||||
});
|
||||
const work = createButton({
|
||||
label: names.length ? `작업 열기 — ${names[current] ?? names[0]} ›` : "작업 열기 ›",
|
||||
onClick: () => openStage(p, current),
|
||||
});
|
||||
work.dataset.openWork = "";
|
||||
bar.append(
|
||||
el("h2", { text: p.name }),
|
||||
kindBadge(p.project_kind),
|
||||
el("span", { className: "b01-proj-mini", text: sub }),
|
||||
el("span", { className: "b01-proj-sp" }),
|
||||
more,
|
||||
work,
|
||||
menu.element,
|
||||
);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function render(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement {
|
||||
const left: HTMLElement[] = [];
|
||||
const kpis = numbers(detail);
|
||||
if (detail.route) {
|
||||
left.push(pane("노선 미리보기", routeMap(detail.route.points), ...(kpis ? [kpis] : [])));
|
||||
} else if (kpis) {
|
||||
left.push(pane("핵심 숫자", kpis));
|
||||
}
|
||||
const right = [
|
||||
infoPane(detail),
|
||||
linkPane(detail, ctx),
|
||||
outputPane(detail),
|
||||
changePane(detail),
|
||||
].filter((x): x is HTMLElement => x !== null);
|
||||
const crumb = el("button", {
|
||||
className: "b01-pd-crumb",
|
||||
text: "프로젝트 ›",
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
crumb.addEventListener("click", () => ctx.go("projects"));
|
||||
return el("div", {
|
||||
className: "b01-pd",
|
||||
children: [
|
||||
crumb,
|
||||
head(detail, ctx),
|
||||
pane("단계 진행 · 누르면 그 단계로", steps(detail)),
|
||||
el("div", {
|
||||
className: `b01-pd-grid${left.length ? "" : " is-single"}`,
|
||||
children: [
|
||||
el("div", { className: "b01-pd-col", children: left }),
|
||||
el("div", { className: "b01-pd-col", children: right }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 「프로젝트 상세」 — 등록부 mount · id = 주소 `id` */
|
||||
export function mountProjectDetail(host: HTMLElement, ctx: ProjectViewCtx): () => void {
|
||||
let alive = true;
|
||||
const id = currentQuery().get("id") ?? "";
|
||||
host.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" }));
|
||||
void fetchProjectDetail(id).then(
|
||||
(detail) => alive && host.replaceChildren(render(detail, ctx)),
|
||||
(error) => {
|
||||
if (!alive) return;
|
||||
const back = createButton({ label: "프로젝트 목록", onClick: () => ctx.go("projects") });
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "b01-proj-empty",
|
||||
children: [
|
||||
el("p", { text: error instanceof Error ? error.message : String(error) }),
|
||||
back,
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_ProjectDetail_Map.ts
|
||||
* 상세 지도 미리보기(PLAN 548-2) — 주소 지도(`UI_MapPreview`)와 같은 배경 타일 길(서버가 VWorld 를 받아 넘김)
|
||||
* 위에 노선 선 · 시점 · 종점. 라이브러리 없이 SVG 한 장(타일 = image · 선 = path) — 칸 너비에 맞춰 줄어듦.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
const W = 640;
|
||||
const H = 260;
|
||||
const TILE = 256;
|
||||
const PAD = 28;
|
||||
const MAX_ZOOM = 17;
|
||||
const MIN_ZOOM = 6;
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 위경도 → 그 배율의 전역 픽셀(웹 메르카토르) */
|
||||
function toPixel(lat: number, lon: number, zoom: number): [number, number] {
|
||||
const size = TILE * 2 ** zoom;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
return [
|
||||
((lon + 180) / 360) * size,
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * size,
|
||||
];
|
||||
}
|
||||
|
||||
/** 선이 판(여백 뺌)에 다 드는 가장 큰 배율 */
|
||||
export function fitZoom(points: [number, number][]): number {
|
||||
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
|
||||
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
|
||||
const xs = px.map((p) => p[0]);
|
||||
const ys = px.map((p) => p[1]);
|
||||
if (
|
||||
Math.max(...xs) - Math.min(...xs) <= W - 2 * PAD &&
|
||||
Math.max(...ys) - Math.min(...ys) <= H - 2 * PAD
|
||||
) {
|
||||
return zoom;
|
||||
}
|
||||
}
|
||||
return MIN_ZOOM;
|
||||
}
|
||||
|
||||
function node<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const element = document.createElementNS(NS, tag);
|
||||
for (const [key, value] of Object.entries(attrs)) element.setAttribute(key, String(value));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 노선 지도 — points = [lat, lon] 두 점 넘게 */
|
||||
export function routeMap(points: [number, number][]): SVGSVGElement {
|
||||
const zoom = fitZoom(points);
|
||||
const px = points.map(([lat, lon]) => toPixel(lat, lon, zoom));
|
||||
const xs = px.map((p) => p[0]);
|
||||
const ys = px.map((p) => p[1]);
|
||||
// 판 왼쪽 위 = 선 가운데 - 판 반
|
||||
const left = (Math.min(...xs) + Math.max(...xs)) / 2 - W / 2;
|
||||
const top = (Math.min(...ys) + Math.max(...ys)) / 2 - H / 2;
|
||||
const svg = node("svg", { viewBox: `0 0 ${W} ${H}`, class: "b01-pd-map" });
|
||||
const limit = 2 ** zoom;
|
||||
for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + W) / TILE); tx += 1) {
|
||||
for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + H) / TILE); ty += 1) {
|
||||
if (tx < 0 || ty < 0 || tx >= limit || ty >= limit) continue;
|
||||
svg.append(
|
||||
node("image", {
|
||||
href: `${API_BASE_URL}/dashboard/map/tile/${zoom}/${tx}/${ty}`,
|
||||
x: tx * TILE - left,
|
||||
y: ty * TILE - top,
|
||||
width: TILE,
|
||||
height: TILE,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
const local = px.map(([x, y]) => [x - left, y - top] as const);
|
||||
svg.append(
|
||||
node("path", {
|
||||
class: "b01-pd-map__line",
|
||||
d: local.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "),
|
||||
}),
|
||||
);
|
||||
const ends: [readonly [number, number], string][] = [
|
||||
[local[0], "시점"],
|
||||
[local[local.length - 1], "종점"],
|
||||
];
|
||||
for (const [[x, y], label] of ends) {
|
||||
svg.append(node("circle", { class: "b01-pd-map__end", cx: x, cy: y, r: 5 }));
|
||||
const text = node("text", { class: "b01-pd-map__label", x: x + 8, y: y - 6 });
|
||||
text.textContent = label;
|
||||
svg.append(text);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
@@ -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,130 @@
|
||||
/* =============================================================================
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 썸네일 선 — 카드 요약(547-1) 0~1 칸 좌표(y 아래로) · 비율 그대로 가운데 · 없으면 빈 판 */
|
||||
export function thumbSvg(points: number[][] | null | undefined): SVGElement {
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
svg.setAttribute("viewBox", "-0.1 -0.1 1.2 1.2");
|
||||
svg.classList.add("b01-proj-thumb");
|
||||
if (!points || points.length < 2) return svg;
|
||||
const path = document.createElementNS(ns, "path");
|
||||
path.setAttribute("d", points.map(([x, y], i) => `${i ? "L" : "M"}${x} ${y}`).join(" "));
|
||||
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,192 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_ProjectList_Views.ts
|
||||
* 프로젝트 목록 보기 셋(PLAN 548-1) — 카드 · 표(줄 끝 ⋯) · 칸반(칸 = 서버 `board` · 완료 칸 접힘).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createMapContextMenu,
|
||||
type MapContextMenu,
|
||||
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), 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)),
|
||||
});
|
||||
}
|
||||
|
||||
/** ⋯ 메뉴 — 오른쪽 끝을 단추 오른쪽에 맞춤(host = position 기준 · 메뉴 element 를 품은 것) */
|
||||
export function openMenuAt(
|
||||
menu: MapContextMenu,
|
||||
host: HTMLElement,
|
||||
button: HTMLElement,
|
||||
items: MapContextMenuItem[],
|
||||
): void {
|
||||
const box = host.getBoundingClientRect();
|
||||
const at = button.getBoundingClientRect();
|
||||
menu.open(at.right - box.left, at.bottom - box.top, items);
|
||||
menu.element.style.left = `${at.right - box.left - menu.element.offsetWidth}px`;
|
||||
}
|
||||
|
||||
/** 줄 끝 ⋯ — 지금 있는 동작만(수정 · 삭제) */
|
||||
export 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();
|
||||
openMenuAt(menu, wrap, more, 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;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createLineChart } from "@ui/ui_template_elements";
|
||||
import type { ResourceData } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function percent(value?: number | null): string {
|
||||
return value == null ? "-" : `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
export function buildResourcePanel(resources: ResourceData | null): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-dashboard__resource-panel";
|
||||
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
|
||||
const values = resources?.current;
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__metric-grid";
|
||||
const items = [
|
||||
[L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)],
|
||||
[L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0],
|
||||
[L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0],
|
||||
];
|
||||
for (const [label, value] of items) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-dashboard__metric";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "b01-dashboard__metric-label";
|
||||
labelEl.textContent = String(label);
|
||||
const valueEl = document.createElement("span");
|
||||
valueEl.className = "b01-dashboard__metric-value";
|
||||
valueEl.textContent = String(value);
|
||||
box.append(labelEl, valueEl);
|
||||
grid.append(box);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function buildResourceChart(resources: ResourceData | null): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-dashboard__chart";
|
||||
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b01-dashboard__chart-caption";
|
||||
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
|
||||
box.append(caption);
|
||||
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b01-dashboard__chart-content";
|
||||
box.append(chartWrap);
|
||||
|
||||
const history = resources?.history ?? [];
|
||||
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] =
|
||||
[];
|
||||
const now = new Date();
|
||||
const baseDate = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
now.getHours() >= 12 ? 24 : 12,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
// 7일을 1시간 버킷으로 분할. 버킷이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐
|
||||
// 점 하나(선 없음)가 되므로, 가동 시간대가 연속 버킷으로 이어지도록 1시간 단위 사용.
|
||||
const HOURS = 7 * 24;
|
||||
for (let i = 0; i <= HOURS; i++) {
|
||||
const time = new Date(baseDate.getTime() - (HOURS - i) * 60 * 60 * 1000);
|
||||
points.push({ timestamp: time, cpu: null, mem: null, disk: null });
|
||||
}
|
||||
|
||||
history.forEach((h) => {
|
||||
if (!h.timestamp) return;
|
||||
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
|
||||
const hTime = new Date(tsStr);
|
||||
let closestIdx = -1;
|
||||
let minDiff = Infinity;
|
||||
|
||||
points.forEach((p, idx) => {
|
||||
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
|
||||
if (diff < minDiff && diff <= 30 * 60 * 1000) {
|
||||
minDiff = diff;
|
||||
closestIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestIdx !== -1) {
|
||||
const p = points[closestIdx];
|
||||
p.cpu = h.cpu_usage_percent;
|
||||
p.mem = h.memory_usage_percent;
|
||||
p.disk = h.disk_usage_percent;
|
||||
}
|
||||
});
|
||||
|
||||
const xLabels = points.map((p) => {
|
||||
const hours = p.timestamp.getHours();
|
||||
return hours === 0 ? `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}` : "";
|
||||
});
|
||||
|
||||
let resizeTimeout: number | null = null;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const width = Math.floor(entry.contentRect.width);
|
||||
if (width <= 0) continue;
|
||||
|
||||
if (resizeTimeout) {
|
||||
window.cancelAnimationFrame(resizeTimeout);
|
||||
}
|
||||
|
||||
resizeTimeout = window.requestAnimationFrame(() => {
|
||||
chartWrap.innerHTML = "";
|
||||
const chart = createLineChart({
|
||||
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
|
||||
xLabels,
|
||||
width: width,
|
||||
height: 140,
|
||||
series: [
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_Cpu"),
|
||||
colorIndex: 0,
|
||||
values: points.map((p) => p.cpu),
|
||||
},
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_Memory"),
|
||||
colorIndex: 1,
|
||||
values: points.map((p) => p.mem),
|
||||
},
|
||||
{
|
||||
name: L("B01_Dashboard_Metric_Disk"),
|
||||
colorIndex: 2,
|
||||
values: points.map((p) => p.disk),
|
||||
},
|
||||
],
|
||||
});
|
||||
chartWrap.append(chart);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(box);
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/* 대시보드 홈(PLAN 547-2) — 색 · 간격 = 셸 변수(--b01-*) · 종류 색 = 테마 차트 변수 */
|
||||
.b01-home {
|
||||
--b01-home-road: var(--b01-accent);
|
||||
--b01-home-plan: var(--color-chart-0);
|
||||
--b01-home-stab: var(--color-chart-2);
|
||||
--b01-home-warn: var(--color-warning);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
color: var(--b01-text);
|
||||
}
|
||||
|
||||
.b01-home__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b01-home__head h2 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.b01-home__push {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b01-home__muted {
|
||||
color: var(--b01-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.b01-home__stale {
|
||||
color: var(--b01-home-warn);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b01-home__alert {
|
||||
color: var(--b01-alert);
|
||||
}
|
||||
|
||||
.b01-home__seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b01-home__seg button {
|
||||
border: 0;
|
||||
background: var(--b01-card);
|
||||
color: var(--b01-text);
|
||||
padding: var(--spacing-4) var(--spacing-12);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-home__seg button.on {
|
||||
background: var(--b01-accent);
|
||||
color: var(--b01-accent-text);
|
||||
}
|
||||
|
||||
.b01-home__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.b01-home__main,
|
||||
.b01-home__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-home__pane {
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-home__pane > .ui-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.b01-home__pane-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.b01-home__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 종류 배지 · 썸네일 · 단계 점 */
|
||||
.b01-home__kind--road {
|
||||
--kind: var(--b01-home-road);
|
||||
}
|
||||
.b01-home__kind--plan {
|
||||
--kind: var(--b01-home-plan);
|
||||
}
|
||||
.b01-home__kind--stability {
|
||||
--kind: var(--b01-home-stab);
|
||||
}
|
||||
.b01-home__kind--erosion {
|
||||
--kind: var(--b01-muted);
|
||||
}
|
||||
|
||||
.b01-home__badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 5px;
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
color: var(--kind);
|
||||
border: 1px solid var(--kind);
|
||||
}
|
||||
|
||||
.b01-home__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-home__title b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-home__thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: var(--b01-accent-soft);
|
||||
}
|
||||
|
||||
.b01-home__thumb polyline {
|
||||
fill: none;
|
||||
stroke: var(--kind);
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.b01-home__thumb--big {
|
||||
height: 210px;
|
||||
}
|
||||
.b01-home__thumb--mid {
|
||||
height: 100px;
|
||||
}
|
||||
.b01-home__thumb--row {
|
||||
width: 80px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.b01-home__thumb--row polyline {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b01-home__pips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.b01-home__pips i {
|
||||
flex: 1;
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--b01-line);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.b01-home__pips i.ok {
|
||||
background: var(--kind);
|
||||
}
|
||||
|
||||
.b01-home__pips i.cur {
|
||||
background: var(--b01-card);
|
||||
border: 2px solid var(--kind);
|
||||
}
|
||||
|
||||
.b01-home__next {
|
||||
border: 1px solid var(--b01-home-warn);
|
||||
border-radius: 8px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 1 — 큰 카드 */
|
||||
.b01-home__big {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.b01-home__big-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
}
|
||||
|
||||
.b01-home__big-info {
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-home__big-info .b01-home__title b {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.b01-home__metrics {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
border-top: 1px solid var(--b01-line);
|
||||
}
|
||||
|
||||
.b01-home__metrics div {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 1px solid var(--b01-line);
|
||||
}
|
||||
|
||||
.b01-home__metrics div:first-child {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.b01-home__metrics b {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b01-home__metrics span {
|
||||
color: var(--b01-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* 2~3 — 중간 카드 */
|
||||
.b01-home__mids {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.b01-home__mid {
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b01-home__mid--first {
|
||||
border-color: var(--b01-accent);
|
||||
}
|
||||
|
||||
.b01-home__mid-body {
|
||||
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 4~8 — 한 줄 목록 */
|
||||
.b01-home__split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.b01-home__split--list {
|
||||
grid-template-columns: minmax(0, 1fr) 220px;
|
||||
}
|
||||
|
||||
.b01-home__split--list > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-home__rows {
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b01-home__row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px minmax(0, 1.2fr) minmax(0, 1fr) auto;
|
||||
gap: var(--spacing-12);
|
||||
align-items: center;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-top: 1px solid var(--b01-line);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-home__row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.b01-home__row:hover,
|
||||
.b01-home__row:focus-visible {
|
||||
background: var(--b01-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.b01-home__row--stale {
|
||||
box-shadow: inset 3px 0 0 var(--b01-home-warn);
|
||||
}
|
||||
|
||||
.b01-home__member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.b01-home__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
|
||||
.b01-home__stack {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--b01-line);
|
||||
}
|
||||
|
||||
.b01-home__stack i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.b01-home__stack--start {
|
||||
background: var(--b01-accent-soft);
|
||||
}
|
||||
.b01-home__stack--working {
|
||||
background: var(--b01-accent);
|
||||
}
|
||||
.b01-home__stack--last {
|
||||
background: var(--b01-home-warn);
|
||||
}
|
||||
|
||||
/* 9 넘음 — 요약 숫자 · 그래프 */
|
||||
.b01-home__many {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.b01-home__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.b01-home__kpi {
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.b01-home__kpi b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b01-home__bars text {
|
||||
font-size: 10px;
|
||||
fill: var(--b01-text);
|
||||
}
|
||||
|
||||
.b01-home__bars rect {
|
||||
fill: var(--b01-accent);
|
||||
}
|
||||
|
||||
.b01-home__bars rect.rest {
|
||||
fill: var(--b01-accent-soft);
|
||||
}
|
||||
|
||||
.b01-home__donut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-home__donut circle {
|
||||
fill: none;
|
||||
stroke: var(--kind);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.b01-home__donut text {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
fill: var(--b01-text);
|
||||
}
|
||||
|
||||
.b01-home__legend {
|
||||
display: grid;
|
||||
gap: var(--spacing-4);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.b01-home__legend span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b01-home__swatch {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 3px;
|
||||
background: var(--kind);
|
||||
}
|
||||
|
||||
/* 오른쪽 — 처리할 일 · 처음 세 걸음 */
|
||||
.b01-home__todo,
|
||||
.b01-home__step {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.b01-home__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.b01-home__dot--plan {
|
||||
background: var(--b01-home-plan);
|
||||
}
|
||||
.b01-home__dot--warn {
|
||||
background: var(--b01-home-warn);
|
||||
}
|
||||
.b01-home__dot--alert {
|
||||
background: var(--b01-alert);
|
||||
}
|
||||
|
||||
.b01-home__step-no {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--b01-accent);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
|
||||
.b01-home__step--done .b01-home__step-no {
|
||||
background: var(--b01-accent);
|
||||
color: var(--b01-accent-text);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.b01-home__body,
|
||||
.b01-home__split,
|
||||
.b01-home__split--list,
|
||||
.b01-home__big-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.b01-home__row {
|
||||
grid-template-columns: 60px minmax(0, 1fr) auto;
|
||||
}
|
||||
.b01-home__row-stage {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Style_Pages.css
|
||||
* 대시보드 세부 페이지 공용 꼴(549) — 머리 · 표(줄 40 · 세로선 없음) · ⋯ · 탭 · 설정 줄 · 빈 상태.
|
||||
* 색 · 간격은 `.b01-shell` 변수(--b01-*)만. 페이지 전용 조각은 아래 쪽에 이어 붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
.b01-pg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 1120px;
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.b01-pg__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg__desc {
|
||||
margin: 4px 0 0;
|
||||
color: var(--b01-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.b01-pg__action {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.b01-pg__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 카드 · 표 */
|
||||
.b01-pg-card {
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
}
|
||||
.b01-pg-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.b01-pg-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.b01-pg-table th {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--b01-muted);
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b01-pg-table td {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.b01-pg-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.b01-pg-table tbody tr:hover {
|
||||
background: var(--b01-hover);
|
||||
}
|
||||
.b01-pg-table .is-end {
|
||||
text-align: right;
|
||||
}
|
||||
.b01-pg-table td.b01-pg-menucell {
|
||||
width: 44px;
|
||||
padding: 0 8px;
|
||||
text-align: right;
|
||||
}
|
||||
.b01-pg-table tr.is-sub > td {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: var(--b01-bg);
|
||||
}
|
||||
.b01-pg-table tr.is-sub:hover {
|
||||
background: var(--b01-bg);
|
||||
}
|
||||
|
||||
/* 이름 칸 */
|
||||
.b01-pg-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
min-width: 0;
|
||||
}
|
||||
.b01-pg-name__main {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b01-pg-name__sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-muted {
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 배지 */
|
||||
.b01-pg-badge {
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
padding: 1px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 줄 끝 ⋯ */
|
||||
.b01-pg-menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.b01-pg-menu__btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--b01-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-pg-menu__btn:hover,
|
||||
.b01-pg-menu.is-open .b01-pg-menu__btn {
|
||||
background: var(--b01-hover);
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg-menu__pop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
z-index: 20;
|
||||
min-width: 132px;
|
||||
padding: 4px;
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 12%);
|
||||
text-align: left;
|
||||
}
|
||||
.b01-pg-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--b01-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b01-pg-menu__item:hover {
|
||||
background: var(--b01-hover);
|
||||
}
|
||||
.b01-pg-menu__item.is-danger {
|
||||
color: var(--b01-alert);
|
||||
}
|
||||
|
||||
/* 탭 */
|
||||
.b01-pg-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
}
|
||||
.b01-pg-tabs__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: -1px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--b01-muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-pg-tabs__tab:hover {
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg-tabs__tab.is-active {
|
||||
color: var(--b01-accent);
|
||||
border-bottom-color: var(--b01-accent);
|
||||
}
|
||||
.b01-pg-tabs__count {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--b01-alert);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.b01-pg-tabs__count[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 설정 줄(Linear 꼴) */
|
||||
.b01-pg-settings {
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-pg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 52px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
}
|
||||
.b01-pg-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.b01-pg-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.b01-pg-row__desc {
|
||||
font-size: 12px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-row__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
}
|
||||
.b01-pg-row__side .ui-field__label:empty {
|
||||
display: none;
|
||||
}
|
||||
.b01-pg-row__value {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.b01-pg-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.b01-pg-empty__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
.b01-pg-empty__text {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.b01-pg-empty__hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--b01-accent);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 단추 맞춤 — 셸 안 단추 높이 */
|
||||
.b01-pg .ui-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── 임시 보관함 ──────────────────────────────────────────────────────────── */
|
||||
.b01-pg-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
.b01-pg-left__bar {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--b01-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-pg-left__bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--b01-accent);
|
||||
}
|
||||
.b01-pg-left.is-soon {
|
||||
color: var(--b01-alert);
|
||||
font-weight: 600;
|
||||
}
|
||||
.b01-pg-left.is-soon .b01-pg-left__bar > i {
|
||||
background: var(--b01-alert);
|
||||
}
|
||||
.b01-pg-caret {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--b01-muted);
|
||||
cursor: pointer;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.b01-pg-caret.is-open {
|
||||
transform: none;
|
||||
}
|
||||
.b01-pg-files {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.b01-pg-files td {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
}
|
||||
.b01-pg-files tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.b01-pg-files td:first-child {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.b01-pg-progress {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.b01-pg-progress__bar {
|
||||
width: 96px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--b01-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-pg-progress__bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--b01-accent);
|
||||
}
|
||||
.b01-pg-meta {
|
||||
color: var(--b01-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 사람 · 회사 ──────────────────────────────────────────────────────────── */
|
||||
.b01-pg-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.b01-pg-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.b01-pg-invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.b01-pg-invite__form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.b01-pg-invite__form > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.b01-pg-note {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
|
||||
/* 배지 색 — 셸 변수에서 (밝음 · 어두움 같이) */
|
||||
.b01-pg .ui-tag {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.b01-pg .ui-tag--success {
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
.b01-pg .ui-tag--accent {
|
||||
background: transparent;
|
||||
border-color: var(--b01-accent);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
.b01-pg .ui-tag--neutral {
|
||||
background: var(--b01-hover);
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg .ui-tag--warning {
|
||||
background: color-mix(in srgb, #d97706 16%, transparent);
|
||||
color: #b45309;
|
||||
}
|
||||
.b01-pg .ui-tag--danger {
|
||||
background: color-mix(in srgb, var(--b01-alert) 14%, transparent);
|
||||
color: var(--b01-alert);
|
||||
}
|
||||
:root[data-theme="dark"] .b01-pg .ui-tag--warning {
|
||||
color: #f5a524;
|
||||
}
|
||||
|
||||
/* ── 시스템 관리 · 내 정보 (549-2) ────────────────────────────────────────── */
|
||||
.b01-shell {
|
||||
--b01-warn: #d9822b;
|
||||
--b01-s1: var(--b01-accent);
|
||||
--b01-s2: #3b82c4;
|
||||
--b01-s3: var(--b01-warn);
|
||||
}
|
||||
.b01-sys-loading {
|
||||
margin: 0;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-sys-tool {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.b01-sys-input {
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: 8px;
|
||||
background: var(--b01-card);
|
||||
color: var(--b01-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.b01-sys-input:focus-visible {
|
||||
outline: 2px solid var(--b01-accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
.b01-pg-row__side .b01-sys-input {
|
||||
width: 240px;
|
||||
}
|
||||
.b01-sys-count {
|
||||
margin-left: auto;
|
||||
color: var(--b01-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.b01-sys-group__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg__body > .b01-sys-group + .b01-sys-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
a.b01-pg-name__main {
|
||||
color: var(--b01-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
a.b01-pg-name__main:hover {
|
||||
color: var(--b01-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.b01-sys-error {
|
||||
color: var(--b01-alert);
|
||||
font-size: 12px;
|
||||
}
|
||||
.b01-sys-assetslot .ui-field__label {
|
||||
display: none;
|
||||
}
|
||||
.b01-sys-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-sys-seg button {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: var(--b01-card);
|
||||
color: var(--b01-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-sys-seg button[aria-pressed="true"] {
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 개요 — 숫자 카드 · 게이지 · 7 일 그래프 */
|
||||
.b01-sys-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.b01-sys-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px;
|
||||
}
|
||||
.b01-sys-stat__label {
|
||||
color: var(--b01-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.b01-sys-stat__value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.b01-sys-gauge .b01-sys-stat__value {
|
||||
font-size: 22px;
|
||||
}
|
||||
.b01-sys-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.b01-sys-gauge__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.b01-sys-gauge__bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--b01-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-sys-gauge__fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--b01-accent);
|
||||
}
|
||||
.b01-sys-gauge.is-warn .b01-sys-gauge__fill {
|
||||
background: var(--b01-warn);
|
||||
}
|
||||
.b01-sys-gauge.is-bad .b01-sys-gauge__fill {
|
||||
background: var(--b01-alert);
|
||||
}
|
||||
.b01-sys-gauge.is-warn .b01-sys-stat__value {
|
||||
color: var(--b01-warn);
|
||||
}
|
||||
.b01-sys-gauge.is-bad .b01-sys-stat__value {
|
||||
color: var(--b01-alert);
|
||||
}
|
||||
.b01-sys-chart {
|
||||
padding: 16px;
|
||||
}
|
||||
.b01-sys-chart__legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--b01-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.b01-sys-chart__legend i {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.b01-sys-chart svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.b01-sys-chart text {
|
||||
fill: var(--b01-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.b01-sys-chart line {
|
||||
stroke: var(--b01-line);
|
||||
}
|
||||
|
||||
/* 공용 단추 눌림 색 — 기본이 보라(royal-amethyst · mist-violet)라 셸 강조 색으로 */
|
||||
.b01-pg .ui-btn--filled:hover:not(:disabled) {
|
||||
background-color: var(--b01-accent);
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
.b01-pg .ui-btn--ghost:hover:not(:disabled) {
|
||||
background-color: var(--b01-accent-soft);
|
||||
}
|
||||
.b01-sys-back {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--b01-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-sys-back:hover {
|
||||
color: var(--b01-text);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/* =============================================================================
|
||||
* 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-card, var(--color-surface, #fff));
|
||||
--pj-sunken: var(--b01-bg, var(--color-surface-sunken, #eef0f3));
|
||||
--pj-border: var(--b01-line, var(--color-border, #e6e8ec));
|
||||
--pj-text: var(--b01-text, var(--color-text, #1f2328));
|
||||
--pj-muted: var(--b01-muted, var(--color-text-muted, #6b7280));
|
||||
--pj-radius: var(--b01-radius, var(--radius-cards, 12px));
|
||||
--pj-gap: var(--spacing-16, 14px);
|
||||
--pj-road: var(--pj-accent);
|
||||
--pj-plan: #2f5fbf;
|
||||
--pj-stab: #c26a0f;
|
||||
--pj-warn: #d97706;
|
||||
--pj-danger: var(--b01-alert, 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 상세 ────────────────────────────────────────────────────────────────── */
|
||||
.b01-pd-crumb {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--pj-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.b01-pd-head {
|
||||
position: relative;
|
||||
}
|
||||
.b01-pd-pane {
|
||||
background: var(--pj-surface);
|
||||
border: 1px solid var(--pj-border);
|
||||
border-radius: var(--pj-radius);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: var(--pj-gap);
|
||||
}
|
||||
.b01-pd-pane h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.b01-pd-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--n, 7), minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.b01-pd-step {
|
||||
border: 1px solid var(--pj-border);
|
||||
border-radius: 9px;
|
||||
padding: 7px 4px;
|
||||
background: var(--pj-surface);
|
||||
color: var(--pj-text);
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-pd-step:hover {
|
||||
border-color: var(--pj-accent);
|
||||
}
|
||||
.b01-pd-step span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0 auto 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--pj-sunken);
|
||||
color: var(--pj-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.b01-pd-step b {
|
||||
font-weight: 500;
|
||||
}
|
||||
.b01-pd-step.is-done span {
|
||||
background: var(--pj-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.b01-pd-step.is-cur {
|
||||
border-color: var(--pj-accent);
|
||||
background: var(--pj-accent-soft);
|
||||
}
|
||||
.b01-pd-step.is-cur span {
|
||||
background: var(--pj-surface);
|
||||
color: var(--pj-accent);
|
||||
border: 2px solid var(--pj-accent);
|
||||
}
|
||||
.b01-pd-step.is-stale span {
|
||||
background: #d97706;
|
||||
color: #fff;
|
||||
}
|
||||
.b01-pd-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.55fr 1fr;
|
||||
gap: var(--pj-gap);
|
||||
align-items: start;
|
||||
}
|
||||
.b01-pd-grid.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.b01-pd-grid.is-single > .b01-pd-col:first-child {
|
||||
display: none;
|
||||
}
|
||||
.b01-pd-map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
background: var(--pj-sunken);
|
||||
}
|
||||
.b01-pd-map__line {
|
||||
fill: none;
|
||||
stroke: #c0392b;
|
||||
stroke-width: 3.5;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.b01-pd-map__end {
|
||||
fill: #fff;
|
||||
stroke: #c0392b;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.b01-pd-map__label {
|
||||
font-size: 12px;
|
||||
fill: #1f2328;
|
||||
paint-order: stroke;
|
||||
stroke: #fff;
|
||||
stroke-width: 3px;
|
||||
}
|
||||
.b01-pd-kpis {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 22px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.b01-pd-kpi b {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.b01-pd-kpi span {
|
||||
color: var(--pj-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.b01-pd-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
.b01-pd-kv span {
|
||||
color: var(--pj-muted);
|
||||
}
|
||||
.b01-pd-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--pj-border);
|
||||
}
|
||||
.b01-pd-link:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
.b01-pd-timeline {
|
||||
border-left: 2px solid var(--pj-border);
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.b01-pd-timeline div {
|
||||
position: relative;
|
||||
padding: 4px 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.b01-pd-timeline div::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -17px;
|
||||
top: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--pj-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.b01-pd-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.b01-pd-steps {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,22 @@
|
||||
/* 대시보드 셸(546) — 색 · 간격은 .b01-shell 안 변수만(프로젝트 화면 영향 0) · 간격 = 4 의 배수 */
|
||||
/* 대시보드 셸(546) — 색 · 둥글기 = 공통 값(ui_template_theme.css)의 별칭 · 간격 = 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-accent: var(--color-accent);
|
||||
--b01-accent-soft: var(--color-accent-soft);
|
||||
--b01-accent-text: var(--color-primary-text);
|
||||
--b01-bg: var(--color-bg);
|
||||
--b01-card: var(--color-surface-raised);
|
||||
--b01-line: var(--color-border);
|
||||
--b01-text: var(--color-text);
|
||||
--b01-muted: var(--color-text-muted);
|
||||
--b01-hover: var(--color-hover);
|
||||
--b01-alert: var(--color-danger-fill);
|
||||
--b01-radius: var(--radius-cards);
|
||||
--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);
|
||||
--color-text: var(--b01-text);
|
||||
--color-text-body: var(--b01-text);
|
||||
--color-text-secondary: var(--b01-muted);
|
||||
--color-text-muted: var(--b01-muted);
|
||||
/* 셸 안 판 바탕 = 카드 흰색 */
|
||||
--color-surface: var(--color-surface-raised);
|
||||
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
@@ -34,19 +25,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,133 +1,8 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_Style_Temp.css
|
||||
* 임시 보관함 섹션 — 그룹(임시 프로젝트명) + 하위 파일 표, 등록 모달
|
||||
* 표·모달 껍데기는 대시보드 공통 스타일을 그대로 쓰고, 여기서는 그룹 카드와
|
||||
* 진행률 막대만 정의한다. 색·간격은 theme.css 변수만 사용한다.
|
||||
* 임시 보관함 — 등록 · 파일 추가 모달 조각(목록 표는 `B01_Dashboard_UI_Style_Pages.css`).
|
||||
* ========================================================================== */
|
||||
|
||||
/* 제목 + 보관 기간 태그를 한 묶음으로 — 공용 섹션 헤더의 왼쪽 칸에 들어간다. */
|
||||
.b01-temp__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-temp__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
/* 그룹 = 임시 프로젝트명 1건. 아래에 파일 표가 붙는다. */
|
||||
.b01-temp__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-paper);
|
||||
}
|
||||
|
||||
/* 프로젝트로 옮긴 묶음은 이력이라 흐리게 — 지금 쓸 수 있는 자료와 구분한다. */
|
||||
.b01-temp__group.is-linked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 제목 행 전체가 접기/펼치기 트리거다. */
|
||||
.b01-temp__group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-temp__group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.b01-temp__caret {
|
||||
width: 12px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b01-temp__group-meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 기본은 접힘 — 펼친 그룹만 파일 표를 보여 준다. */
|
||||
.b01-temp__group-body {
|
||||
display: none;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-temp__group.is-open .b01-temp__group-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 파일 표는 행이 많아 촘촘하게 — 글자 크기는 그대로 두고 상하 여백만 줄인다. */
|
||||
.b01-temp__group-body .ui-general-block__table th,
|
||||
.b01-temp__group-body .ui-general-block__table td {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.b01-temp__group-body .ui-general-block__table .ui-btn {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b01-temp__badge {
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-temp__group.is-completed .b01-temp__badge {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.b01-temp__group.is-uploading .b01-temp__badge {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 업로드 진행률은 모달이 아니라 해당 파일 행 안에서 보여 준다(2026-08-08 사용자 지시). */
|
||||
.b01-temp__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 140px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.b01-temp__progress-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b01-temp__progress-bar > div {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── 등록/추가 모달 ───────────────────────────────────────────────────── */
|
||||
|
||||
.b01-temp__hidden-input {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { table, text } from "@ui/ui_template_general_blocks";
|
||||
import { bytesText } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */
|
||||
@@ -20,13 +21,7 @@ export function tempFileType(fileName: string): string {
|
||||
return index >= 0 ? fileName.slice(index + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function formatTempBytes(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
const mb = bytes / 1024 / 1024;
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
export const formatTempBytes = bytesText;
|
||||
|
||||
export interface TempModalOptions {
|
||||
title: string;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_TempUpload.ts
|
||||
* 대시보드 임시 보관함 — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
||||
*
|
||||
* 화면 구성은 프로젝트/사용자 관리 컨테이너와 같다(2026-08-08 사용자 지시):
|
||||
* - 섹션 우측 상단 [+] 버튼으로 등록 모달 소환
|
||||
* - 그룹(임시 프로젝트명) 아래에 파일 리스트(표)
|
||||
* - 업로드 진행률은 그 파일 행 안에서 표시(모달 없음)
|
||||
* - 그룹별 [파일 추가], 파일별 [삭제] — 삭제 시 임시 저장소 실제 파일도 지운다
|
||||
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있다.
|
||||
* 임시 보관함 본체(549-1) — 프로젝트를 만들기 전에 자료를 먼저 올려 두는 곳.
|
||||
* 목록 = 묶음 표(이름 · 상태 · 남은 날 막대 · 올린 날 · 줄 끝 ⋯) · 줄을 펼치면 그 안 파일 · 진행률
|
||||
* ⋯ = 파일 추가 · 삭제(확인) · 파일 줄 ⋯ = 삭제(확인) · 삭제하면 임시 저장소 실제 파일도 지움
|
||||
* 대용량 라이다는 청크로 나눠 올리고 새로고침 후에도 이어올릴 수 있음
|
||||
* 화면 틀(머리 · 주 단추)은 `B01_Dashboard_UI_View_Temp.ts`
|
||||
* ========================================================================== */
|
||||
|
||||
import { UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { limitVisibleRows, section, table, text } from "@ui/ui_template_general_blocks";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { icon } from "@ui/ui_template_icons";
|
||||
import {
|
||||
bytesText,
|
||||
daysLeft,
|
||||
EXPIRY_WARN_DAYS,
|
||||
isExpiringSoon,
|
||||
leftPercent,
|
||||
} from "./logic/B01_Dashboard_Logic_Pages";
|
||||
import {
|
||||
createTempBatch,
|
||||
createTempUploadSession,
|
||||
@@ -25,8 +29,18 @@ import {
|
||||
uploadTempChunk,
|
||||
type TempBatchItem,
|
||||
} from "./B01_Dashboard_Api_Temp";
|
||||
import { DASHBOARD_VISIBLE_ROWS, L } from "./B01_Dashboard_UI_Common";
|
||||
import { formatTempBytes, openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import {
|
||||
badge,
|
||||
emptyState,
|
||||
nameCell,
|
||||
primaryButton,
|
||||
rowMenu,
|
||||
tableRow,
|
||||
tableShell,
|
||||
type Column,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import { openTempFileModal, tempFileType } from "./B01_Dashboard_UI_TempModal";
|
||||
import "./B01_Dashboard_UI_Style_Temp.css";
|
||||
|
||||
/** 청크 이어올리기 표식 — 새로고침 후에도 같은 세션을 잇는다. */
|
||||
@@ -65,40 +79,48 @@ function formatDate(value: string | null): string {
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 보관함 섹션(제목 + [+] 버튼 + 그룹 목록)을 통째로 만든다.
|
||||
* 목록은 내부에서 다시 그린다(전체 페이지 재렌더 없음).
|
||||
*/
|
||||
export function buildTempUploadSection(): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-temp";
|
||||
const COLUMNS: Column[] = [
|
||||
{ label: "보관 이름" },
|
||||
{ label: "상태", width: "120px" },
|
||||
{ label: "남은 기간", width: "210px" },
|
||||
{ label: "올린 날", width: "110px" },
|
||||
{ label: "", width: "44px" },
|
||||
];
|
||||
|
||||
// 보관 기간은 제목 옆 태그로 알린다(서버가 준 값으로 목록 조회 때 갱신).
|
||||
const retentionTag = createTag(`${30}${L("B01_Temp_Hint_Days")}`, "neutral");
|
||||
export interface TempBox {
|
||||
root: HTMLElement;
|
||||
/** 새 자료 등록 모달 — 머리 주 단추가 부름 */
|
||||
openCreate: () => void;
|
||||
}
|
||||
|
||||
/** 보관함 본체 — 표 · 빈 상태 · 업로드를 한 덩이로 · 목록은 안에서 다시 그림(전체 페이지 재렌더 없음) */
|
||||
export function buildTempBox(onGuide: () => void): TempBox {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b01-pg__body";
|
||||
const note = document.createElement("p");
|
||||
note.className = "b01-pg-note";
|
||||
const listHost = document.createElement("div");
|
||||
listHost.className = "b01-temp__list";
|
||||
panel.append(listHost);
|
||||
root.append(note, listHost);
|
||||
|
||||
/** 업로드 중인 파일 — batch_id별 작업표. 서버 목록과 합쳐 행을 그린다. */
|
||||
const tasks = new Map<string, UploadTask[]>();
|
||||
/** 진행률 막대 DOM — 청크 하나 올릴 때마다 목록 전체를 다시 그리지 않기 위해 잡아 둔다. */
|
||||
const progressNodes = new Map<string, { fill: HTMLElement; caption: HTMLElement }>();
|
||||
/** 펼쳐 둔 그룹 — 기본은 접힘, 사용자가 연 그룹만 목록을 다시 그려도 열린 상태를 유지한다. */
|
||||
/** 펼쳐 둔 묶음 — 목록을 다시 그려도 열린 상태를 유지한다. */
|
||||
const openGroups = new Set<string>();
|
||||
/** 4개 높이 제한을 다시 재는 함수 — 그룹을 펼치면 높이가 달라져 다시 재야 한다. */
|
||||
let remeasureGroups: (() => void) | null = null;
|
||||
let retentionDays = 30;
|
||||
let lastBatches: TempBatchItem[] = [];
|
||||
|
||||
/* ── 행 구성 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function progressCell(key: string, percent: number, label: string): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-temp__progress";
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "b01-pg-progress";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = `${label} ${Math.round(percent)}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b01-temp__progress-bar";
|
||||
const fill = document.createElement("div");
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "b01-pg-progress__bar";
|
||||
const fill = document.createElement("i");
|
||||
fill.style.width = `${percent}%`;
|
||||
bar.append(fill);
|
||||
wrap.append(caption, bar);
|
||||
@@ -113,181 +135,195 @@ export function buildTempUploadSection(): HTMLElement {
|
||||
node.caption.textContent = `${L("B01_Temp_File_Status_Uploading")} ${Math.round(task.percent)}%`;
|
||||
}
|
||||
|
||||
function fileRows(batch: TempBatchItem): HTMLElement[][] {
|
||||
const rows: HTMLElement[][] = [];
|
||||
|
||||
for (const file of batch.files) {
|
||||
// 목록에는 아직 프로젝트에 안 쓴 묶음만 온다 — 항상 지울 수 있다.
|
||||
const action = createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void removeFile(batch, file.file_type, file.original_filename),
|
||||
});
|
||||
rows.push([
|
||||
text(file.file_type.toUpperCase()),
|
||||
text(file.original_filename),
|
||||
text(formatTempBytes(file.file_size_bytes)),
|
||||
text(L("B01_Temp_File_Status_Stored")),
|
||||
action,
|
||||
]);
|
||||
function fileLine(cells: (string | Node)[]): HTMLElement {
|
||||
const tr = document.createElement("tr");
|
||||
for (const cell of cells) {
|
||||
const td = document.createElement("td");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** 펼친 묶음 안 파일 표 — 올린 파일 · 올리는 중 · 이어올리기 대기 */
|
||||
function filesTable(batch: TempBatchItem): HTMLElement {
|
||||
const table = document.createElement("table");
|
||||
table.className = "b01-pg-files";
|
||||
const body = document.createElement("tbody");
|
||||
for (const file of batch.files) {
|
||||
body.append(
|
||||
fileLine([
|
||||
file.file_type.toUpperCase(),
|
||||
file.original_filename,
|
||||
bytesText(file.file_size_bytes),
|
||||
badge(L("B01_Temp_File_Status_Stored"), "success"),
|
||||
rowMenu([
|
||||
{
|
||||
label: L("Common_Btn_Delete"),
|
||||
danger: true,
|
||||
confirm: `${file.original_filename}\n${L("B01_Temp_File_Delete_Confirm")}`,
|
||||
onClick: () => void removeFile(batch, file.file_type),
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const running = tasks.get(batch.batch_id) ?? [];
|
||||
for (const task of running) {
|
||||
const status =
|
||||
task.status === "failed"
|
||||
? text(L("B01_Temp_File_Status_Failed"))
|
||||
? badge(L("B01_Temp_File_Status_Failed"), "danger")
|
||||
: task.status === "waiting"
|
||||
? text(L("B01_Temp_File_Status_Waiting"))
|
||||
? badge(L("B01_Temp_File_Status_Waiting"), "neutral")
|
||||
: progressCell(
|
||||
`${batch.batch_id}::${task.key}`,
|
||||
task.percent,
|
||||
L("B01_Temp_File_Status_Uploading"),
|
||||
);
|
||||
rows.push([
|
||||
text(task.fileType.toUpperCase()),
|
||||
text(task.fileName),
|
||||
text(formatTempBytes(task.sizeBytes)),
|
||||
status,
|
||||
text("-"),
|
||||
]);
|
||||
body.append(
|
||||
fileLine([
|
||||
task.fileType.toUpperCase(),
|
||||
task.fileName,
|
||||
bytesText(task.sizeBytes),
|
||||
status,
|
||||
"",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// 다른 창·이전 세션에서 올리다 만 파일 — 이 창의 작업표에는 없으므로 서버 값으로 보여 준다.
|
||||
for (const pending of batch.pending_sessions) {
|
||||
const known = running.some((task) => task.fileName === pending.original_filename);
|
||||
if (known) continue;
|
||||
rows.push([
|
||||
text(tempFileType(pending.original_filename).toUpperCase()),
|
||||
text(pending.original_filename),
|
||||
text(formatTempBytes(pending.file_size_bytes)),
|
||||
progressCell(
|
||||
`${batch.batch_id}::pending::${pending.upload_session_id}`,
|
||||
pending.progress_percent,
|
||||
L("B01_Temp_File_Status_Paused"),
|
||||
),
|
||||
text("-"),
|
||||
]);
|
||||
if (running.some((task) => task.fileName === pending.original_filename)) continue;
|
||||
body.append(
|
||||
fileLine([
|
||||
tempFileType(pending.original_filename).toUpperCase(),
|
||||
pending.original_filename,
|
||||
bytesText(pending.file_size_bytes),
|
||||
progressCell(
|
||||
`${batch.batch_id}::pending::${pending.upload_session_id}`,
|
||||
pending.progress_percent,
|
||||
L("B01_Temp_File_Status_Paused"),
|
||||
),
|
||||
"",
|
||||
]),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
if (!body.children.length)
|
||||
body.append(fileLine(["", "파일 없음 · 묶음 ⋯ 에서 파일 추가", "", "", ""]));
|
||||
table.append(body);
|
||||
return table;
|
||||
}
|
||||
|
||||
function renderGroup(batch: TempBatchItem): HTMLElement {
|
||||
const group = document.createElement("div");
|
||||
group.className = `b01-temp__group is-${batch.status}`;
|
||||
|
||||
const opened = openGroups.has(batch.batch_id);
|
||||
group.classList.toggle("is-open", opened);
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "b01-temp__group-head";
|
||||
|
||||
// 제목 행 = 접기/펼치기 트리거. 이름·상태·요약이 한 줄에 들어간다.
|
||||
const titleRow = document.createElement("div");
|
||||
titleRow.className = "b01-temp__group-title";
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "b01-temp__caret";
|
||||
caret.textContent = opened ? "▾" : "▸";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = batch.name;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "b01-temp__badge";
|
||||
badge.textContent = batch.required_complete
|
||||
? L("B01_Temp_Status_Ready")
|
||||
: L("B01_Temp_Status_Uploading");
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "b01-temp__group-meta";
|
||||
const parts = [
|
||||
`${batch.files.length}${L("B01_Temp_Meta_FileCount")}`,
|
||||
formatTempBytes(batch.total_size_bytes),
|
||||
];
|
||||
if (batch.expires_at) {
|
||||
parts.push(`${L("B01_Temp_Meta_Expires")} ${formatDate(batch.expires_at)}`);
|
||||
/** 남은 날 칸 — 막대 + 글 · 7 일 안 = 빨강 */
|
||||
function leftCell(batch: TempBatchItem): HTMLElement {
|
||||
const left = daysLeft(batch.expires_at);
|
||||
const cell = document.createElement("span");
|
||||
// 다 올리기 전에는 만료가 정해지지 않음 — 막대 없이 「-」
|
||||
if (left === null) {
|
||||
cell.className = "b01-pg-meta";
|
||||
cell.textContent = "-";
|
||||
return cell;
|
||||
}
|
||||
meta.textContent = parts.join(" · ");
|
||||
titleRow.append(caret, title, badge, meta);
|
||||
cell.className = `b01-pg-left${isExpiringSoon(left) ? " is-soon" : ""}`;
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "b01-pg-left__bar";
|
||||
const fill = document.createElement("i");
|
||||
fill.style.width = `${leftPercent(left, retentionDays)}%`;
|
||||
bar.append(fill);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = left === 0 ? "오늘 만료" : `${left}일 남음`;
|
||||
cell.append(bar, label);
|
||||
return cell;
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
// 버튼 클릭이 접기/펼치기까지 건드리지 않게 막는다.
|
||||
actions.addEventListener("click", (event) => event.stopPropagation());
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("B01_Temp_Btn_Add"),
|
||||
variant: "ghost",
|
||||
onClick: () => openAddModal(batch),
|
||||
}),
|
||||
createButton({
|
||||
label: L("Common_Btn_Delete"),
|
||||
variant: "danger",
|
||||
onClick: () => void removeBatch(batch),
|
||||
}),
|
||||
);
|
||||
head.append(titleRow, actions);
|
||||
head.setAttribute("aria-expanded", String(opened));
|
||||
head.addEventListener("click", () => {
|
||||
const nowOpen = !group.classList.contains("is-open");
|
||||
group.classList.toggle("is-open", nowOpen);
|
||||
head.setAttribute("aria-expanded", String(nowOpen));
|
||||
caret.textContent = nowOpen ? "▾" : "▸";
|
||||
// 목록을 다시 그려도 열어 둔 그룹은 계속 열려 있어야 한다(업로드 중 진행률 확인).
|
||||
if (nowOpen) openGroups.add(batch.batch_id);
|
||||
else openGroups.delete(batch.batch_id);
|
||||
// 펼치면 그 묶음이 커진다 — 4개까지 보이는 높이를 다시 잡는다.
|
||||
remeasureGroups?.();
|
||||
function nameWithCaret(batch: TempBatchItem, opened: boolean): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-pg-person";
|
||||
const caret = document.createElement("button");
|
||||
caret.type = "button";
|
||||
caret.className = `b01-pg-caret${opened ? " is-open" : ""}`;
|
||||
caret.title = caret.ariaLabel = "파일 펼치기";
|
||||
caret.setAttribute("aria-expanded", String(opened));
|
||||
caret.append(icon("chevronDown", 14));
|
||||
caret.addEventListener("click", () => {
|
||||
if (!openGroups.delete(batch.batch_id)) openGroups.add(batch.batch_id);
|
||||
draw(lastBatches);
|
||||
});
|
||||
const sub = `${batch.files.length}${L("B01_Temp_Meta_FileCount")} · ${bytesText(batch.total_size_bytes)}`;
|
||||
wrap.append(caret, nameCell(batch.name, sub));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "b01-temp__group-body";
|
||||
body.append(
|
||||
table(
|
||||
[
|
||||
L("B01_Temp_Table_Type"),
|
||||
L("B01_Temp_Table_Name"),
|
||||
L("B01_Temp_Table_Size"),
|
||||
L("B01_Temp_Table_Status"),
|
||||
L("B01_Temp_Table_Action"),
|
||||
],
|
||||
fileRows(batch),
|
||||
),
|
||||
function renderBatch(batch: TempBatchItem, tbody: HTMLElement): void {
|
||||
const opened = openGroups.has(batch.batch_id);
|
||||
const status = batch.required_complete
|
||||
? badge(L("B01_Temp_Status_Ready"), "success")
|
||||
: badge(L("B01_Temp_Status_Uploading"), "warning");
|
||||
tbody.append(
|
||||
tableRow(COLUMNS, [
|
||||
nameWithCaret(batch, opened),
|
||||
status,
|
||||
leftCell(batch),
|
||||
formatDate(batch.created_at),
|
||||
rowMenu([
|
||||
{ label: L("B01_Temp_Btn_Add"), onClick: () => openAddModal(batch) },
|
||||
{
|
||||
label: L("Common_Btn_Delete"),
|
||||
danger: true,
|
||||
confirm: `${batch.name}\n${L("B01_Temp_Delete_Confirm")}`,
|
||||
onClick: () => void removeBatch(batch),
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
if (!opened) return;
|
||||
const sub = document.createElement("tr");
|
||||
sub.className = "is-sub";
|
||||
const cell = document.createElement("td");
|
||||
cell.colSpan = COLUMNS.length;
|
||||
cell.append(filesTable(batch));
|
||||
sub.append(cell);
|
||||
tbody.append(sub);
|
||||
}
|
||||
|
||||
group.append(head, body);
|
||||
return group;
|
||||
function draw(batches: TempBatchItem[]): void {
|
||||
lastBatches = batches;
|
||||
progressNodes.clear();
|
||||
note.textContent = `보관 기간 ${retentionDays}일 · 만료 ${EXPIRY_WARN_DAYS}일 전부터 빨강`;
|
||||
note.hidden = batches.length === 0;
|
||||
if (batches.length === 0) {
|
||||
listHost.replaceChildren(
|
||||
emptyState({
|
||||
icon: "archive",
|
||||
text: "임시 보관함이 비어 있음",
|
||||
hint: "프로젝트를 만들기 전에 측량 파일을 먼저 올려 둘 수 있음",
|
||||
action: primaryButton("자료 올리기", openCreateModal, "upload"),
|
||||
onGuide,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const shell = tableShell(COLUMNS);
|
||||
for (const batch of batches) renderBatch(batch, shell.tbody);
|
||||
listHost.replaceChildren(shell.root);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetchTempBatches();
|
||||
retentionTag.textContent = `${response.retention_days}${L("B01_Temp_Hint_Days")}`;
|
||||
progressNodes.clear();
|
||||
listHost.replaceChildren();
|
||||
if (response.batches.length === 0) {
|
||||
// 빈 상태 문구·서식은 프로젝트 컨테이너와 같게 공용 표 블록을 그대로 쓴다.
|
||||
listHost.append(table([], []));
|
||||
return;
|
||||
}
|
||||
for (const batch of response.batches) listHost.append(renderGroup(batch));
|
||||
// 묶음이 쌓여도 컨테이너는 4개 높이까지만 — 나머지는 안쪽 스크롤.
|
||||
// 표가 아니라 묶음이 한 행이다. 묶음 안 파일 표는 접혀 있어 대상이 아니다.
|
||||
remeasureGroups = limitVisibleRows(
|
||||
listHost,
|
||||
() => [...listHost.querySelectorAll<HTMLElement>(":scope > .b01-temp__group")],
|
||||
DASHBOARD_VISIBLE_ROWS,
|
||||
);
|
||||
retentionDays = response.retention_days;
|
||||
draw(response.batches);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B01_Temp_Load_Failed"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 삭제 ────────────────────────────────────────────────────────────── */
|
||||
/* ── 삭제 — 확인은 ⋯ 메뉴가 먼저 받음 ─────────────────────────────────── */
|
||||
|
||||
async function removeBatch(batch: TempBatchItem): Promise<void> {
|
||||
if (!window.confirm(`${batch.name}\n${L("B01_Temp_Delete_Confirm")}`)) return;
|
||||
try {
|
||||
await deleteTempBatch(batch.batch_id);
|
||||
tasks.delete(batch.batch_id);
|
||||
openGroups.delete(batch.batch_id);
|
||||
showToast(L("B01_Temp_Delete_Success"), "success");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
@@ -295,12 +331,7 @@ export function buildTempUploadSection(): HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(
|
||||
batch: TempBatchItem,
|
||||
fileType: string,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
if (!window.confirm(`${fileName}\n${L("B01_Temp_File_Delete_Confirm")}`)) return;
|
||||
async function removeFile(batch: TempBatchItem, fileType: string): Promise<void> {
|
||||
try {
|
||||
await deleteTempBatchFile(batch.batch_id, fileType);
|
||||
showToast(L("B01_Temp_File_Delete_Success"), "success");
|
||||
@@ -453,17 +484,5 @@ export function buildTempUploadSection(): HTMLElement {
|
||||
}
|
||||
|
||||
void refresh();
|
||||
|
||||
const root = section(L("B01_Temp_Section"), panel, true, [
|
||||
createButton({ label: "+", onClick: openCreateModal }),
|
||||
]);
|
||||
// 공용 섹션 헤더는 [제목 | 액션] 2단이라, 보관 기간 태그를 제목과 한 묶음으로 감싼다.
|
||||
const heading = root.querySelector("h3");
|
||||
if (heading) {
|
||||
const titleWrap = document.createElement("div");
|
||||
titleWrap.className = "b01-temp__section-title";
|
||||
heading.replaceWith(titleWrap);
|
||||
titleWrap.append(heading, retentionTag);
|
||||
}
|
||||
return root;
|
||||
return { root, openCreate: openCreateModal };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Assigned.ts
|
||||
* 세부 페이지 「받은 예정노선」(549-1) — 산림청이 우리 회사에 지정한 예정노선 연도 표.
|
||||
* 0 건이어도 메뉴 · 페이지 보임 · 줄 끝 ⋯ = 보기(D02) · 연결(모달)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { goToWorkflowStage } from "../A00_Common/b_workflow_nav";
|
||||
import { kstText } from "../common_util/common_util_time_kst";
|
||||
import { fetchAssignedPlans, type AssignedPlan } from "./B01_Dashboard_UI_AssignedPlans";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
emptyState,
|
||||
nameCell,
|
||||
pageFrame,
|
||||
rowMenu,
|
||||
type Cell,
|
||||
type Column,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import { openPlanLinkModal } from "./B01_Dashboard_UI_PlanLink";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{ label: L("DE_New_Plan") },
|
||||
{ label: L("DE_Assigned_Year"), width: "90px" },
|
||||
{ label: L("D04_Col_Confirmed"), width: "150px" },
|
||||
{ label: L("DE_Assigned_State"), width: "220px" },
|
||||
{ label: "", width: "44px" },
|
||||
];
|
||||
|
||||
function stateCell(plan: AssignedPlan): HTMLElement {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "b01-pg-inline";
|
||||
cell.append(
|
||||
plan.linked_project_id
|
||||
? badge(L("DE_Assigned_Linked"), "success")
|
||||
: badge(L("DE_Assigned_Unlinked"), "neutral"),
|
||||
);
|
||||
if (plan.update_available) cell.append(badge(L("DE_Assigned_Update"), "warning"));
|
||||
return cell;
|
||||
}
|
||||
|
||||
function planRow(plan: AssignedPlan): Cell[] {
|
||||
const view = (): void => void goToWorkflowStage(plan.project_id, ROUTES.D02_PLAN_BASE);
|
||||
return [
|
||||
nameCell(plan.project_name),
|
||||
String(plan.year),
|
||||
kstText(plan.confirmed_at),
|
||||
stateCell(plan),
|
||||
rowMenu([
|
||||
{ label: L("DE_Assigned_View"), onClick: view },
|
||||
{
|
||||
label: L("DE_Assigned_Link"),
|
||||
onClick: () =>
|
||||
void openPlanLinkModal(plan.assignee_id, `${plan.project_name} ${plan.year}`),
|
||||
},
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export async function mountAssigned(host: HTMLElement, ctx: ViewCtx): Promise<void> {
|
||||
const page = pageFrame(
|
||||
host,
|
||||
L("DE_Assigned_Title"),
|
||||
"산림청이 지정한 예정노선이 여기 모임 · 연결하면 임도 설계가 그 노선으로 시작됨",
|
||||
);
|
||||
const plans = await fetchAssignedPlans().catch(() => []);
|
||||
if (!host.isConnected) return;
|
||||
page.body.append(
|
||||
plans.length
|
||||
? dataTable(COLUMNS, plans.map(planRow))
|
||||
: emptyState({
|
||||
icon: "route",
|
||||
text: "아직 받은 예정노선이 없음",
|
||||
hint: "산림청이 우리 회사에 예정노선을 지정하면 여기에 나타남",
|
||||
onGuide: () => ctx.go("guide"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Company.ts
|
||||
* 세부 페이지 「회사 정보」(549-1) — 설정 줄 카드(이름 · 사업자번호 · 대표자 · 주소 · 상태 · 수 · 로고).
|
||||
* 회사 관리자 · 시스템 관리자 = [회사 정보 수정] · 로고 고르기 / 일반 · 산림청 = 보기만
|
||||
* 시스템 관리자 = 머리에서 고른 회사 · 회사 없음 = [회사 생성] [회사 검색]
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchCompanyAssets,
|
||||
fetchUserCompany,
|
||||
setCompanyLogo,
|
||||
type CompanyInfo,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import {
|
||||
openCreateCompanyModal,
|
||||
openEditCompanyModal,
|
||||
openFindCompanyModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import {
|
||||
badge,
|
||||
emptyState,
|
||||
pageFrame,
|
||||
primaryButton,
|
||||
settingCard,
|
||||
settingRow,
|
||||
valueText,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
import { statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
|
||||
async function loadCompany(ctx: ViewCtx): Promise<CompanyInfo | null> {
|
||||
if (ctx.role === "SYSTEM_ADMIN") {
|
||||
const all = await fetchAllCompanies().catch(() => []);
|
||||
return all.find((c) => c.id === ctx.companyId) ?? null;
|
||||
}
|
||||
return fetchUserCompany().catch(() => null);
|
||||
}
|
||||
|
||||
/** 로고 줄 — 관리자는 고르기 · 그 밖은 등록 여부만 */
|
||||
function logoRow(company: CompanyInfo, ctx: ViewCtx, canEdit: boolean): HTMLElement {
|
||||
const desc = "도면 표제란에 들어감 · 프로젝트가 따로 고르지 않으면 이 로고를 씀";
|
||||
if (!canEdit) {
|
||||
return settingRow("회사 로고", desc, valueText(company.logo_asset_id ? "등록됨" : null));
|
||||
}
|
||||
const slot = document.createElement("div");
|
||||
void fetchCompanyAssets(company.id).then((assets) => {
|
||||
slot.append(
|
||||
createAssetField("", "LOGO", assets, company.logo_asset_id ?? null, company.id, ctx.me, {
|
||||
onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined),
|
||||
}).root,
|
||||
);
|
||||
});
|
||||
return settingRow("회사 로고", desc, slot);
|
||||
}
|
||||
|
||||
export async function mountCompany(host: HTMLElement, ctx: ViewCtx): Promise<void> {
|
||||
const page = pageFrame(host, "회사 정보", "회사 이름 · 사업자 정보 · 도면에 들어가는 로고");
|
||||
const company = await loadCompany(ctx);
|
||||
if (!host.isConnected) return;
|
||||
if (!company) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-pg-inline";
|
||||
actions.append(
|
||||
primaryButton(L("B01_Dashboard_CreateCompany"), () => openCreateCompanyModal()),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_FindCompany"),
|
||||
variant: "ghost",
|
||||
onClick: () => openFindCompanyModal(),
|
||||
}),
|
||||
);
|
||||
page.body.append(
|
||||
emptyState({
|
||||
icon: "building",
|
||||
text: "소속 회사가 없음",
|
||||
hint: "회사를 만들거나 이미 있는 회사를 찾아 가입 요청을 보낼 수 있음",
|
||||
action: actions,
|
||||
onGuide: () => ctx.go("guide"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 회사 정보 수정 · 로고 = 관리자만(범위는 백엔드가 다시 봄)
|
||||
const canEdit = ctx.role === "ADMIN" || ctx.role === "SYSTEM_ADMIN";
|
||||
if (canEdit)
|
||||
page.setAction(primaryButton("회사 정보 수정", () => openEditCompanyModal(company), "pencil"));
|
||||
const status = company.business_status ? statusBadgeOf(company.business_status) : null;
|
||||
page.body.append(
|
||||
settingCard([
|
||||
settingRow("회사 이름", "", valueText(company.name)),
|
||||
settingRow(
|
||||
L("B01_Dashboard_Field_BusinessNumber"),
|
||||
"",
|
||||
valueText(company.business_registration_number),
|
||||
),
|
||||
settingRow(L("B01_Dashboard_Field_Owner"), "", valueText(company.business_owner)),
|
||||
settingRow(L("B01_Dashboard_Field_Address"), "", valueText(company.business_address)),
|
||||
settingRow("사업자 상태", "", status ? badge(status.text, status.tone) : valueText(null)),
|
||||
settingRow(L("B01_Dashboard_Metric_ActiveUsers"), "", valueText(company.user_count ?? 0)),
|
||||
settingRow(L("B01_Dashboard_Projects"), "", valueText(company.project_count ?? 0)),
|
||||
logoRow(company, ctx, canEdit),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_CompanyDefaults.ts
|
||||
* 세부 페이지 「회사 설계 기본값」(549-1) — 구조물 조합 회사 층 등 M02 로 가는 설정 줄.
|
||||
* 줄 규칙 = `settingLinks` 한 벌(역할로 카드 숨김 · 회사 관리자만 수정 · 그 밖은 읽기)
|
||||
* ========================================================================== */
|
||||
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { settingLinks } from "../A00_Common/m_settings_nav";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { emptyState, pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
export function mountCompanyDefaults(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const page = pageFrame(
|
||||
host,
|
||||
"회사 설계 기본값",
|
||||
"새 프로젝트가 처음 받는 구조물 조합 · 회사 층은 회사 관리자가 고침",
|
||||
);
|
||||
const links = settingLinks(ctx.me, ctx.companyId);
|
||||
page.body.append(
|
||||
links.length
|
||||
? settingCard(
|
||||
links.map((link) =>
|
||||
settingRow(
|
||||
link.title,
|
||||
link.desc,
|
||||
createButton({
|
||||
label: ctx.role === "ADMIN" ? "열기 · 수정" : "열기",
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(link.route, link.query),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
: emptyState({ icon: "adjustments", text: "표시할 설정이 없음" }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Me.ts
|
||||
* 내 정보(549-2) — Linear 설정 꼴: 묶음 제목 + 둥근 상자 안 「제목 · 설명 · 오른쪽 조작」 줄.
|
||||
* 기본정보 · 보안(비밀번호) · 화면(밝음 · 어두움) · 내 설계 기본값(구조물 조합 개인 층).
|
||||
* ========================================================================== */
|
||||
|
||||
import { THEME_STORAGE_KEY } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { settingLinks } from "../A00_Common/m_settings_nav";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
|
||||
import { buildPasswordCard, buildProfileCard } from "./B01_Dashboard_UI_Profile";
|
||||
import { el, group } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const currentTheme = (): Theme =>
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
|
||||
function setTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
/** 밝음 · 어두움 두 칸 — 고르면 바로 바뀌고 이 브라우저에 기억 */
|
||||
function themeSwitch(): HTMLElement {
|
||||
const seg = el("div", "b01-sys-seg");
|
||||
const options: [Theme, string][] = [
|
||||
["light", "밝음"],
|
||||
["dark", "어두움"],
|
||||
];
|
||||
const sync = (): void => {
|
||||
for (const b of seg.querySelectorAll("button"))
|
||||
b.setAttribute("aria-pressed", String(b.dataset.theme === currentTheme()));
|
||||
};
|
||||
for (const [theme, label] of options) {
|
||||
const b = el("button", "", label);
|
||||
b.type = "button";
|
||||
b.dataset.theme = theme;
|
||||
b.addEventListener("click", () => {
|
||||
setTheme(theme);
|
||||
sync();
|
||||
});
|
||||
seg.append(b);
|
||||
}
|
||||
sync();
|
||||
return seg;
|
||||
}
|
||||
|
||||
export function mountMe(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const page = pageFrame(host, "내 정보", "내 계정과 화면 설정을 관리합니다.");
|
||||
const defaults = settingLinks(ctx.me, ctx.companyId)
|
||||
.filter((link) => link.key === "구조물조합")
|
||||
.map((link) =>
|
||||
settingRow(
|
||||
`${link.title} (내 것)`,
|
||||
link.desc,
|
||||
createButton({
|
||||
label: "열기",
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(link.route, link.query),
|
||||
}),
|
||||
),
|
||||
);
|
||||
page.body.append(
|
||||
group("기본정보", buildProfileCard(ctx.me)),
|
||||
group("보안", buildPasswordCard()),
|
||||
group("화면", settingCard([settingRow("테마", "밝은 화면 · 어두운 화면", themeSwitch())])),
|
||||
...(defaults.length ? [group("내 설계 기본값", settingCard(defaults))] : []),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Parts.ts
|
||||
* 시스템 관리 · 내 정보 페이지(549-2) 곁부품 — 불러오기 틀 · 거르기 줄 · 묶음 제목.
|
||||
* 공용 조각(`B01_Dashboard_UI_PageKit.ts` · sub4)에 없는 것만 · 모양은 `Style_Pages.css` 아래 「시스템 관리」 쪽.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { emptyState } from "./B01_Dashboard_UI_PageKit";
|
||||
|
||||
export const el = <K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className = "",
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* 자료를 읽는 동안 안내 → 다 읽으면 build 가 stage 에 그린 것으로 바꿈.
|
||||
* 실패하면 까닭 + [다시 시도] · 그 사이 떠났으면(host 떨어짐) 안 그림.
|
||||
*/
|
||||
export function loadPage(host: HTMLElement, build: (stage: HTMLElement) => Promise<void>): void {
|
||||
const run = (): void => {
|
||||
host.replaceChildren(el("p", "b01-sys-loading", "불러오는 중…"));
|
||||
const stage = el("div", "b01-sys-stage");
|
||||
build(stage).then(
|
||||
() => host.isConnected && host.replaceChildren(...stage.childNodes),
|
||||
(error: unknown) => {
|
||||
if (!host.isConnected) return;
|
||||
const retry = createButton({ label: "다시 시도", variant: "ghost", onClick: run });
|
||||
const page = el("div", "b01-pg");
|
||||
page.append(
|
||||
emptyState({
|
||||
icon: "bell",
|
||||
text: error instanceof Error ? error.message : "불러오지 못했습니다.",
|
||||
action: retry,
|
||||
}),
|
||||
);
|
||||
host.replaceChildren(page);
|
||||
},
|
||||
);
|
||||
};
|
||||
run();
|
||||
}
|
||||
|
||||
/** 거르기 줄 */
|
||||
export function toolbar(...items: HTMLElement[]): HTMLElement {
|
||||
const bar = el("div", "b01-sys-tool");
|
||||
bar.append(...items);
|
||||
return bar;
|
||||
}
|
||||
|
||||
export function searchBox(placeholder: string, onInput: (value: string) => void): HTMLInputElement {
|
||||
const input = el("input", "b01-sys-input");
|
||||
input.type = "search";
|
||||
input.placeholder = placeholder;
|
||||
input.ariaLabel = placeholder;
|
||||
input.addEventListener("input", () => onInput(input.value.trim().toLowerCase()));
|
||||
return input;
|
||||
}
|
||||
|
||||
export function selectBox(
|
||||
label: string,
|
||||
options: [value: string, text: string][],
|
||||
onChange: (value: string) => void,
|
||||
): HTMLSelectElement {
|
||||
const select = el("select", "b01-sys-input");
|
||||
select.ariaLabel = label;
|
||||
for (const [value, text] of options) select.append(new Option(text, value));
|
||||
select.addEventListener("change", () => onChange(select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
/** 묶음 — 제목 + 아래 내용(설정 카드 · 표) */
|
||||
export function group(title: string, content: Node): HTMLElement {
|
||||
const box = el("section", "b01-sys-group");
|
||||
box.append(el("h3", "b01-sys-group__title", title), content);
|
||||
return box;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_People.ts
|
||||
* 세부 페이지 「사람」(549-1) — 탭 셋: 팀원 · 가입 요청(수 배지) · 초대.
|
||||
* 회사 관리자 = 자기 회사 · 시스템 관리자 = 머리에서 고른 회사
|
||||
* 팀원 줄 ⋯ = 수정 · 역할 변경 · 회사에서 빼기(빨강) — 확인은 그 모달이 맡음
|
||||
* 가입 요청 줄 = [승인] [거절] · 초대 = 이메일로 안내 메일 · 소속 없는 가입자는 [팀원 추가]
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchAllUsers,
|
||||
fetchCompanyJoinRequests,
|
||||
fetchCompanyMembers,
|
||||
inviteMember,
|
||||
type DashboardUser,
|
||||
type JoinRequest,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { formatDate, L } from "./B01_Dashboard_UI_Common";
|
||||
import { onB01_JoinRequest_Process_Click } from "./B01_Dashboard_UI_Company";
|
||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||
import {
|
||||
openAddMemberModal,
|
||||
openChangeRoleModal,
|
||||
openDeleteUserModal,
|
||||
openEditUserModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
emptyState,
|
||||
kitIcon,
|
||||
pageFrame,
|
||||
primaryButton,
|
||||
rowMenu,
|
||||
tabBar,
|
||||
type Cell,
|
||||
type Column,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
import { pendingCount, roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
|
||||
type TabKey = "members" | "requests" | "invite";
|
||||
/** 승인 · 거절 뒤 페이지가 다시 그려져도 보던 탭을 유지 */
|
||||
let lastTab: TabKey = "members";
|
||||
|
||||
const MEMBER_COLUMNS: Column[] = [
|
||||
{ label: "이름" },
|
||||
{ label: "이메일" },
|
||||
{ label: "직급 · 부서" },
|
||||
{ label: "역할", width: "120px" },
|
||||
{ label: "상태", width: "90px" },
|
||||
{ label: "", width: "44px" },
|
||||
];
|
||||
const REQUEST_COLUMNS: Column[] = [
|
||||
{ label: "요청자" },
|
||||
{ label: "이메일" },
|
||||
{ label: "요청일", width: "110px" },
|
||||
{ label: "상태", width: "90px" },
|
||||
{ label: "", width: "150px", align: "end" },
|
||||
];
|
||||
|
||||
function person(name: string): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b01-pg-person";
|
||||
const avatar = document.createElement("span");
|
||||
avatar.className = "b01-pg-avatar";
|
||||
avatar.textContent = (name.trim() || "?").slice(0, 1);
|
||||
const text = document.createElement("span");
|
||||
text.className = "b01-pg-name__main";
|
||||
text.textContent = name || "-";
|
||||
wrap.append(avatar, text);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function memberRow(m: Member, ctx: ViewCtx): Cell[] {
|
||||
const me = ctx.me;
|
||||
const status = statusBadgeOf(m.status);
|
||||
const items = [{ label: L("Common_Btn_Edit"), onClick: () => openEditUserModal(me, m) }];
|
||||
const rows = [...items];
|
||||
if (canChangeRole(me, m)) {
|
||||
rows.push({ label: L("B01_Dashboard_ChangeRole"), onClick: () => openChangeRoleModal(m) });
|
||||
}
|
||||
const system = ctx.role === "SYSTEM_ADMIN";
|
||||
const removable = canDeleteUser(me, m) && (!system || m.id !== me.id);
|
||||
return [
|
||||
person(m.name),
|
||||
m.email,
|
||||
[m.position, m.department].filter(Boolean).join(" · ") || "-",
|
||||
badge(roleText(m.role), m.role === "ADMIN" || m.role === "SYSTEM_ADMIN" ? "accent" : "neutral"),
|
||||
badge(status.text, status.tone),
|
||||
rowMenu([
|
||||
...rows,
|
||||
...(removable
|
||||
? [
|
||||
{
|
||||
label: L(system ? "B01_Dashboard_DeleteUser" : "B01_Dashboard_RemoveMember"),
|
||||
danger: true,
|
||||
ownConfirm: true,
|
||||
onClick: () => openDeleteUserModal(m),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function requestRow(r: JoinRequest, system: boolean): Cell[] {
|
||||
const status = statusBadgeOf(r.status);
|
||||
const waiting = !r.status || r.status.toUpperCase() === "PENDING";
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "b01-pg-inline";
|
||||
if (waiting) {
|
||||
actions.append(
|
||||
createButton({
|
||||
label: L("B01_Dashboard_Approve"),
|
||||
size: "sm",
|
||||
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "APPROVE", system),
|
||||
}),
|
||||
createButton({
|
||||
label: L("B01_Dashboard_Reject"),
|
||||
size: "sm",
|
||||
variant: "danger",
|
||||
onClick: () => void onB01_JoinRequest_Process_Click(r.id, "REJECT", system),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return [
|
||||
person(r.user_name ?? r.user_email),
|
||||
r.user_email,
|
||||
formatDate(r.requested_at),
|
||||
badge(status.text, status.tone),
|
||||
actions,
|
||||
];
|
||||
}
|
||||
|
||||
/** 초대 탭 — 아직 가입 전인 사람에게 안내 메일 · 이미 가입한 사람은 [팀원 추가] */
|
||||
function invitePanel(companyId: number | null | undefined, onAdd: () => void): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b01-pg-card b01-pg-invite";
|
||||
const email = createInputField({
|
||||
label: "초대할 이메일",
|
||||
type: "email",
|
||||
placeholder: "name@company.com",
|
||||
});
|
||||
const send = createButton({
|
||||
label: "초대 메일 보내기",
|
||||
iconStart: kitIcon("mail"),
|
||||
onClick: async () => {
|
||||
const value = email.input.value.trim();
|
||||
if (!value.includes("@")) {
|
||||
email.setError("메일을 보낼 이메일 주소를 입력");
|
||||
return;
|
||||
}
|
||||
email.setError();
|
||||
try {
|
||||
await inviteMember(value, null, companyId);
|
||||
email.input.value = "";
|
||||
showToast("안내 메일을 보냈음", "success");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : L("B01_Dashboard_RequestFailed"),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const form = document.createElement("div");
|
||||
form.className = "b01-pg-invite__form";
|
||||
form.append(email.root, send);
|
||||
const note = document.createElement("p");
|
||||
note.className = "b01-pg-note";
|
||||
note.textContent = "아직 가입하지 않은 사람에게 가입 안내 메일만 보냄 · 계정은 본인이 만듦";
|
||||
const already = document.createElement("p");
|
||||
already.className = "b01-pg-note";
|
||||
already.append("이미 가입했고 소속이 없는 사람은 ");
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "b01-pg-link";
|
||||
link.textContent = "팀원 추가";
|
||||
link.addEventListener("click", onAdd);
|
||||
already.append(link, " 에서 바로 등록");
|
||||
box.append(form, note, already);
|
||||
return box;
|
||||
}
|
||||
|
||||
async function loadPeople(ctx: ViewCtx): Promise<{ members: Member[]; requests: JoinRequest[] }> {
|
||||
const system = ctx.role === "SYSTEM_ADMIN";
|
||||
const [members, requests] = await Promise.all([
|
||||
(system ? fetchAllUsers(ctx.companyId) : fetchCompanyMembers()).catch(() => [] as Member[]),
|
||||
fetchCompanyJoinRequests(system ? ctx.companyId : undefined).catch(() => []),
|
||||
]);
|
||||
return { members: members as DashboardUser[], requests };
|
||||
}
|
||||
|
||||
export async function mountPeople(host: HTMLElement, ctx: ViewCtx): Promise<void> {
|
||||
const system = ctx.role === "SYSTEM_ADMIN";
|
||||
const companyId = system ? ctx.companyId : undefined;
|
||||
const page = pageFrame(host, "사람", "회사 팀원 · 가입 요청 · 초대를 한곳에서 관리");
|
||||
const addMember = (): void => openAddMemberModal(undefined, companyId);
|
||||
page.setAction(primaryButton("팀원 추가", addMember));
|
||||
|
||||
const { members, requests } = await loadPeople(ctx);
|
||||
if (!host.isConnected) return;
|
||||
|
||||
const content = document.createElement("div");
|
||||
const draw = (key: TabKey): void => {
|
||||
if (key === "members") {
|
||||
content.replaceChildren(
|
||||
members.length
|
||||
? dataTable(
|
||||
MEMBER_COLUMNS,
|
||||
members.map((m) => memberRow(m, ctx)),
|
||||
)
|
||||
: emptyState({
|
||||
icon: "users",
|
||||
text: "등록된 팀원이 없음",
|
||||
action: primaryButton("팀원 추가", addMember),
|
||||
onGuide: () => ctx.go("guide"),
|
||||
}),
|
||||
);
|
||||
} else if (key === "requests") {
|
||||
content.replaceChildren(
|
||||
requests.length
|
||||
? dataTable(
|
||||
REQUEST_COLUMNS,
|
||||
requests.map((r) => requestRow(r, system)),
|
||||
)
|
||||
: emptyState({ icon: "users", text: "대기 중인 가입 요청이 없음" }),
|
||||
);
|
||||
} else {
|
||||
content.replaceChildren(invitePanel(companyId, addMember));
|
||||
}
|
||||
};
|
||||
const tabs = tabBar(
|
||||
[
|
||||
{ key: "members", label: "팀원" },
|
||||
{ key: "requests", label: "가입 요청" },
|
||||
{ key: "invite", label: "초대" },
|
||||
],
|
||||
lastTab,
|
||||
(key) => draw((lastTab = key as TabKey)),
|
||||
);
|
||||
tabs.setBadge("requests", pendingCount(requests));
|
||||
page.body.append(tabs.root, content);
|
||||
draw(lastTab);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysCompanies.ts
|
||||
* 시스템 관리 › 회사(549-2) — 회사 목록(찾기 · 줄 끝 ⋯) → 회사 상세(정보 · 사람 · 프로젝트 탭) · [+ 회사].
|
||||
* 상세 = 주소 `&company=<id>`(뒤로 가기 됨) · 수정 · 로고 · 새 회사 = 지금 창(Modals) 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { currentQuery } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllUsers,
|
||||
fetchCompanyProjects,
|
||||
setCompanyLogo,
|
||||
type CompanyInfo,
|
||||
type ProjectItem,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { formatDate } from "./B01_Dashboard_UI_Common";
|
||||
import { filterCompanies } from "./B01_Dashboard_UI_CompanyPick";
|
||||
import { openCreateCompanyModal, openEditCompanyModal } from "./B01_Dashboard_UI_Modals";
|
||||
import { viewHref } from "./B01_Dashboard_UI_Nav";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
emptyState,
|
||||
nameCell,
|
||||
pageFrame,
|
||||
primaryButton,
|
||||
rowMenu,
|
||||
settingCard,
|
||||
settingRow,
|
||||
tabBar,
|
||||
valueText,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import { el, loadPage, searchBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
|
||||
export const KIND_KO: Record<string, string> = {
|
||||
ROAD: "임도설계",
|
||||
PLAN: "예정노선",
|
||||
STABILITY: "안정성평가",
|
||||
EROSION: "사방설계",
|
||||
};
|
||||
|
||||
const statusTag = (s?: string | null): HTMLElement =>
|
||||
badge(s || "-", s === "활동중" ? "success" : "neutral");
|
||||
|
||||
const logoLabel = (c: CompanyInfo): string => (c.logo_asset_id ? "로고 변경…" : "로고 지정…");
|
||||
|
||||
function pickLogo(ctx: ViewCtx, company: CompanyInfo, after?: () => void): void {
|
||||
void openAssetPicker("LOGO", company.id, ctx.me, company.logo_asset_id ?? null, async (id) => {
|
||||
await setCompanyLogo(id, company.id);
|
||||
company.logo_asset_id = id;
|
||||
after?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** 이름 칸 — 이름은 상세로 가는 링크(뒤로 가기 · 새 탭 됨) + 아래 작은 주소 */
|
||||
function linkedName(c: CompanyInfo): HTMLElement {
|
||||
const link = el("a", "b01-pg-name__main", c.name);
|
||||
link.href = viewHref("sys-companies", { company: String(c.id) });
|
||||
const cell = nameCell(c.name, c.business_address);
|
||||
cell.firstElementChild!.replaceWith(link);
|
||||
return cell;
|
||||
}
|
||||
|
||||
const LIST_COLUMNS = [
|
||||
{ label: "회사" },
|
||||
{ label: "사업자등록번호" },
|
||||
{ label: "대표자" },
|
||||
{ label: "상태" },
|
||||
{ label: "사용자", align: "end" as const },
|
||||
{ label: "프로젝트", align: "end" as const },
|
||||
{ label: "" },
|
||||
];
|
||||
|
||||
function companyList(stage: HTMLElement, ctx: ViewCtx, companies: CompanyInfo[]): void {
|
||||
const page = pageFrame(
|
||||
stage,
|
||||
"회사",
|
||||
"등록된 회사를 보고 정보 · 로고를 고칩니다. 이름을 누르면 상세입니다.",
|
||||
);
|
||||
page.setAction(primaryButton("회사", () => openCreateCompanyModal()));
|
||||
const body = el("div");
|
||||
const draw = (query: string): void => {
|
||||
const shown = filterCompanies(companies, query);
|
||||
if (!shown.length) {
|
||||
body.replaceChildren(
|
||||
companies.length
|
||||
? emptyState({ icon: "search", text: "찾는 회사가 없습니다." })
|
||||
: emptyState({
|
||||
icon: "building",
|
||||
text: "등록된 회사가 없습니다.",
|
||||
action: primaryButton("회사", () => openCreateCompanyModal()),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
dataTable(
|
||||
LIST_COLUMNS,
|
||||
shown.map((c) => [
|
||||
linkedName(c),
|
||||
c.business_registration_number ?? "-",
|
||||
c.business_owner ?? "-",
|
||||
statusTag(c.business_status),
|
||||
String(c.user_count ?? 0),
|
||||
String(c.project_count ?? 0),
|
||||
rowMenu([
|
||||
{
|
||||
label: "상세 보기",
|
||||
onClick: () => ctx.go("sys-companies", { company: String(c.id) }),
|
||||
},
|
||||
{ label: "정보 수정", onClick: () => openEditCompanyModal(c) },
|
||||
{ label: logoLabel(c), onClick: () => pickLogo(ctx, c) },
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
};
|
||||
draw("");
|
||||
page.body.append(toolbar(searchBox("회사 이름 · 사업자번호 찾기", draw)), body);
|
||||
}
|
||||
|
||||
const projectTable = (projects: ProjectItem[]): HTMLElement =>
|
||||
projects.length
|
||||
? dataTable(
|
||||
[
|
||||
{ label: "프로젝트" },
|
||||
{ label: "종류" },
|
||||
{ label: "지역" },
|
||||
{ label: "단계" },
|
||||
{ label: "담당" },
|
||||
{ label: "수정일" },
|
||||
],
|
||||
projects.map((p) => [
|
||||
p.name,
|
||||
KIND_KO[p.project_kind ?? "ROAD"] ?? "-",
|
||||
p.region ?? "-",
|
||||
`${p.workflow_stage} 단계`,
|
||||
p.owner_name ?? "-",
|
||||
formatDate(p.updated_at),
|
||||
]),
|
||||
)
|
||||
: emptyState({ icon: "folder", text: "이 회사의 프로젝트가 없습니다." });
|
||||
|
||||
async function companyDetail(
|
||||
stage: HTMLElement,
|
||||
ctx: ViewCtx,
|
||||
company: CompanyInfo,
|
||||
): Promise<void> {
|
||||
const [users, projects] = await Promise.all([
|
||||
fetchAllUsers(company.id).catch(() => []),
|
||||
fetchCompanyProjects(company.id).catch(() => []),
|
||||
]);
|
||||
const page = pageFrame(stage, company.name, "회사 상세");
|
||||
page.setAction(
|
||||
createButton({ label: "정보 수정", onClick: () => openEditCompanyModal(company) }),
|
||||
);
|
||||
const back = el("button", "b01-sys-back", "‹ 회사 목록");
|
||||
back.type = "button";
|
||||
back.addEventListener("click", () => ctx.go("sys-companies"));
|
||||
const logoBtn = createButton({
|
||||
label: logoLabel(company),
|
||||
variant: "ghost",
|
||||
onClick: () => pickLogo(ctx, company, () => (logoBtn.textContent = logoLabel(company))),
|
||||
});
|
||||
const info = settingCard([
|
||||
settingRow("회사 이름", "", valueText(company.name)),
|
||||
settingRow("사업자등록번호", "", valueText(company.business_registration_number)),
|
||||
settingRow("대표자", "", valueText(company.business_owner)),
|
||||
settingRow("주소", "", valueText(company.business_address)),
|
||||
settingRow("상태", "", statusTag(company.business_status)),
|
||||
settingRow("로고", "도면 표제란에 들어갑니다.", logoBtn),
|
||||
]);
|
||||
const people = users.length
|
||||
? dataTable(
|
||||
[
|
||||
{ label: "이름" },
|
||||
{ label: "직급" },
|
||||
{ label: "부서" },
|
||||
{ label: "역할" },
|
||||
{ label: "상태" },
|
||||
{ label: "" },
|
||||
],
|
||||
users.map((u) => {
|
||||
const st = statusBadgeOf(u.status);
|
||||
return [
|
||||
nameCell(u.name, u.email),
|
||||
u.position ?? "-",
|
||||
u.department ?? "-",
|
||||
roleText(u.role),
|
||||
badge(st.text, st.tone),
|
||||
rowMenu([
|
||||
{
|
||||
label: "사용자 페이지에서 보기",
|
||||
onClick: () => ctx.go("sys-users", { company: String(company.id), q: u.email }),
|
||||
},
|
||||
]),
|
||||
];
|
||||
}),
|
||||
)
|
||||
: emptyState({ icon: "users", text: "이 회사에 소속된 사용자가 없습니다." });
|
||||
const panes: Record<string, HTMLElement> = { info, people, projects: projectTable(projects) };
|
||||
const pane = el("div");
|
||||
pane.append(info);
|
||||
const tabs = tabBar(
|
||||
[
|
||||
{ key: "info", label: "정보" },
|
||||
{ key: "people", label: `사람 ${users.length}` },
|
||||
{ key: "projects", label: `프로젝트 ${projects.length}` },
|
||||
],
|
||||
"info",
|
||||
(key) => pane.replaceChildren(panes[key]),
|
||||
);
|
||||
page.body.append(back, tabs.root, pane);
|
||||
}
|
||||
|
||||
export function mountSysCompanies(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const id = Number(currentQuery().get("company"));
|
||||
loadPage(host, async (stage) => {
|
||||
const companies = await fetchAllCompanies();
|
||||
const company = id ? companies.find((c) => c.id === id) : undefined;
|
||||
if (company) await companyDetail(stage, ctx, company);
|
||||
else companyList(stage, ctx, companies);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysDefaults.ts
|
||||
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
|
||||
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { settingLinks, type SettingLink } from "../A00_Common/m_settings_nav";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
|
||||
import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
|
||||
import { group } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
|
||||
const GROUP_OF: Record<string, string> = {
|
||||
m01: "마스터 데이터",
|
||||
구조물조합: "구조물 · 도면",
|
||||
옵션: "구조물 · 도면",
|
||||
도면: "구조물 · 도면",
|
||||
calcSites: "계산",
|
||||
};
|
||||
|
||||
const open = (link: SettingLink): void =>
|
||||
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
|
||||
|
||||
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const groups = new Map<string, HTMLElement[]>();
|
||||
for (const link of settingLinks(ctx.me, ctx.companyId)) {
|
||||
const title = GROUP_OF[link.key] ?? "그 밖";
|
||||
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
|
||||
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
|
||||
}
|
||||
const page = pageFrame(host, "설계 기본값", "새 프로젝트가 처음 받는 마스터 값을 관리합니다.");
|
||||
page.body.append(...[...groups].map(([title, rows]) => group(title, settingCard(rows))));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysLogs.ts
|
||||
* 시스템 관리 › 시스템 로그(549-2) — 동작 · 대상 · 접속 주소 · 일시 표 + 거르기(동작 · 기간 · 찾기).
|
||||
* 서버는 최신순 쪽(100 줄)만 줌 → 받은 줄 안에서 거르고 [이전 기록 더 보기] 로 다음 쪽을 이어 붙임.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchAuditLogPage, type AuditLog } from "./B01_Dashboard_Api_Fetch";
|
||||
import { formatDate, formatTime } from "./B01_Dashboard_UI_Common";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { dataTable, emptyState, pageFrame } from "./B01_Dashboard_UI_PageKit";
|
||||
import { el as h, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
export const ACTION_KO: Record<string, string> = {
|
||||
PROJECT_CREATE: "프로젝트 생성",
|
||||
PROJECT_UPDATE: "프로젝트 수정",
|
||||
PROJECT_DELETE: "프로젝트 삭제",
|
||||
PROJECT_HARD_DELETE: "프로젝트 영구 삭제",
|
||||
COMPANY_CREATE: "회사 생성",
|
||||
CALC_SITE_CHANGE: "계산 자리 변경",
|
||||
RATE_ANOMALY: "호출 이상",
|
||||
M02_OTHER_LAYER_WRITE: "마스터 다른 층 수정",
|
||||
};
|
||||
const TARGET_KO: Record<string, string> = {
|
||||
PROJECT: "프로젝트",
|
||||
COMPANY: "회사",
|
||||
USER: "사용자",
|
||||
ASSET: "자산",
|
||||
M02_PERSONAL: "개인 마스터",
|
||||
CALC_SITE: "계산 자리",
|
||||
};
|
||||
|
||||
export const actionLabel = (action: string): string => ACTION_KO[action] ?? action;
|
||||
|
||||
/** 무엇에 한 일인지 — 종류 + 번호(UUID 는 앞 8 자) */
|
||||
export function targetText(log: AuditLog): string {
|
||||
const kind = TARGET_KO[(log.resource_type ?? "").toUpperCase()] ?? log.resource_type ?? "";
|
||||
const ref = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : "");
|
||||
const short = ref.length > 12 ? `${ref.slice(0, 8)}…` : ref;
|
||||
return kind ? `${kind} ${short}`.trim() : short || "-";
|
||||
}
|
||||
|
||||
export interface LogFilter {
|
||||
action: string;
|
||||
from: string;
|
||||
to: string;
|
||||
q: string;
|
||||
}
|
||||
|
||||
/** 동작 · 기간(날짜 YYYY-MM-DD 양끝 포함) · 찾기(이메일 · 대상 · 접속 주소) */
|
||||
export function filterLogs(logs: AuditLog[], f: LogFilter): AuditLog[] {
|
||||
return logs.filter((l) => {
|
||||
const day = (l.timestamp ?? "").slice(0, 10);
|
||||
if (f.action && l.action !== f.action) return false;
|
||||
if (f.from && day < f.from) return false;
|
||||
if (f.to && day > f.to) return false;
|
||||
if (!f.q) return true;
|
||||
return [l.email, targetText(l), l.ip_address, actionLabel(l.action)]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(f.q);
|
||||
});
|
||||
}
|
||||
|
||||
function dateInput(label: string, onChange: (v: string) => void): HTMLInputElement {
|
||||
const input = h("input", "b01-sys-input");
|
||||
input.type = "date";
|
||||
input.ariaLabel = label;
|
||||
input.title = label;
|
||||
input.addEventListener("change", () => onChange(input.value));
|
||||
return input;
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ label: "사용자" },
|
||||
{ label: "동작" },
|
||||
{ label: "대상" },
|
||||
{ label: "접속 주소" },
|
||||
{ label: "일시" },
|
||||
];
|
||||
|
||||
export function mountSysLogs(host: HTMLElement, _ctx: ViewCtx): void {
|
||||
loadPage(host, async (stage) => {
|
||||
const first = await fetchAuditLogPage(1, PAGE);
|
||||
let logs = first.items;
|
||||
let page = 1;
|
||||
const total = first.total;
|
||||
const f: LogFilter = { action: "", from: "", to: "", q: "" };
|
||||
const body = h("div");
|
||||
const count = h("span", "b01-sys-count");
|
||||
const more = createButton({
|
||||
label: "이전 기록 더 보기",
|
||||
variant: "ghost",
|
||||
onClick: () => void loadMore(),
|
||||
});
|
||||
const actionBox = h("span");
|
||||
const drawActions = (): void => {
|
||||
const kinds = [...new Set(logs.map((l) => l.action))].sort();
|
||||
const select = selectBox(
|
||||
"동작",
|
||||
[["", "모든 동작"], ...kinds.map((a): [string, string] => [a, actionLabel(a)])],
|
||||
(v) => {
|
||||
f.action = v;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
select.value = f.action;
|
||||
actionBox.replaceChildren(select);
|
||||
};
|
||||
const draw = (): void => {
|
||||
const shown = filterLogs(logs, f);
|
||||
count.textContent = `${shown.length} / ${logs.length} 줄 (전체 ${total} 줄)`;
|
||||
more.hidden = logs.length >= total;
|
||||
body.replaceChildren(
|
||||
shown.length
|
||||
? dataTable(
|
||||
COLUMNS,
|
||||
shown.map((l) => {
|
||||
const action = h("span", "", actionLabel(l.action));
|
||||
action.title = l.action;
|
||||
return [
|
||||
l.email ?? "-",
|
||||
action,
|
||||
targetText(l),
|
||||
l.ip_address ?? "-",
|
||||
`${formatDate(l.timestamp)} ${formatTime(l.timestamp)}`.trim(),
|
||||
];
|
||||
}),
|
||||
)
|
||||
: emptyState({
|
||||
icon: "fileText",
|
||||
text: logs.length ? "조건에 맞는 기록이 없습니다." : "기록이 없습니다.",
|
||||
}),
|
||||
);
|
||||
};
|
||||
async function loadMore(): Promise<void> {
|
||||
page += 1;
|
||||
logs = logs.concat((await fetchAuditLogPage(page, PAGE)).items);
|
||||
drawActions();
|
||||
draw();
|
||||
}
|
||||
drawActions();
|
||||
draw();
|
||||
const search = searchBox("사용자 · 대상 · 접속 주소 찾기", (v) => {
|
||||
f.q = v;
|
||||
draw();
|
||||
});
|
||||
const frame = pageFrame(
|
||||
stage,
|
||||
"시스템 로그",
|
||||
"누가 무엇을 했는지 최신순으로 봅니다. 기록은 1 년 보관됩니다.",
|
||||
);
|
||||
const from = dateInput("시작일", (v) => {
|
||||
f.from = v;
|
||||
draw();
|
||||
});
|
||||
const to = dateInput("종료일", (v) => {
|
||||
f.to = v;
|
||||
draw();
|
||||
});
|
||||
frame.body.append(toolbar(search, actionBox, from, h("span", "", "~"), to, count), body, more);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysOverview.ts
|
||||
* 시스템 관리 › 개요(549-2) — 숫자 카드(회사 · 사용자 · 활성 프로젝트 · 활성 사용자) +
|
||||
* CPU · 메모리 · 디스크 게이지(넘으면 주황 · 빨강) + 7 일 그래프(인라인 SVG).
|
||||
* 자료 = 리소스 API(7 일) · 회사 목록 · 전체 사용자 — 시스템 관리자만(서버도 막음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllUsers,
|
||||
fetchSystemResources,
|
||||
type ResourceData,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { pageFrame } from "./B01_Dashboard_UI_PageKit";
|
||||
import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DAY_MS = 86_400_000;
|
||||
/** 게이지 색 바뀌는 선 — 이 % 이상 주황 · 빨강 */
|
||||
export const GAUGE_WARN = 70;
|
||||
export const GAUGE_BAD = 90;
|
||||
|
||||
export const gaugeTone = (percent: number | null | undefined): "ok" | "warn" | "bad" =>
|
||||
percent == null || percent < GAUGE_WARN ? "ok" : percent < GAUGE_BAD ? "warn" : "bad";
|
||||
|
||||
function statCard(label: string, value: number | string): HTMLElement {
|
||||
const card = h("div", "b01-pg-card b01-sys-stat");
|
||||
card.append(
|
||||
h("span", "b01-sys-stat__label", label),
|
||||
h("span", "b01-sys-stat__value", String(value)),
|
||||
);
|
||||
return card;
|
||||
}
|
||||
|
||||
function gauge(label: string, percent: number | null | undefined): HTMLElement {
|
||||
const card = h("div", `b01-pg-card b01-sys-gauge is-${gaugeTone(percent)}`);
|
||||
const top = h("div", "b01-sys-gauge__top");
|
||||
top.append(
|
||||
h("span", "b01-sys-stat__label", label),
|
||||
h("span", "b01-sys-stat__value", percent == null ? "-" : `${Math.round(percent)}%`),
|
||||
);
|
||||
const bar = h("div", "b01-sys-gauge__bar");
|
||||
bar.setAttribute("role", "progressbar");
|
||||
bar.setAttribute("aria-valuenow", String(Math.round(percent ?? 0)));
|
||||
const fill = h("div", "b01-sys-gauge__fill");
|
||||
fill.style.width = `${Math.min(100, Math.max(0, percent ?? 0))}%`;
|
||||
bar.append(fill);
|
||||
card.append(top, bar);
|
||||
return card;
|
||||
}
|
||||
|
||||
const svg = (tag: string, attrs: Record<string, string | number>): SVGElement => {
|
||||
const e = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
|
||||
return e;
|
||||
};
|
||||
|
||||
const SERIES = [
|
||||
{ name: "CPU", key: "cpu_usage_percent", color: "var(--b01-s1)" },
|
||||
{ name: "메모리", key: "memory_usage_percent", color: "var(--b01-s2)" },
|
||||
{ name: "디스크", key: "disk_usage_percent", color: "var(--b01-s3)" },
|
||||
] as const;
|
||||
|
||||
/** 7 일 그래프 — 가로 = 시각 · 세로 0~100 % · 세 시간 넘게 비면 선을 끊음 */
|
||||
export function buildWeekChart(data: ResourceData | null, now = Date.now()): HTMLElement {
|
||||
const W = 720;
|
||||
const H = 180;
|
||||
const pad = { l: 28, r: 8, t: 8, b: 20 };
|
||||
const start = now - 7 * DAY_MS;
|
||||
const x = (t: number): number => pad.l + ((t - start) / (now - start)) * (W - pad.l - pad.r);
|
||||
const y = (v: number): number => pad.t + (1 - v / 100) * (H - pad.t - pad.b);
|
||||
|
||||
const box = h("div", "b01-pg-card b01-sys-chart");
|
||||
const legend = h("div", "b01-sys-chart__legend");
|
||||
for (const s of SERIES) {
|
||||
const item = h("span", "", s.name);
|
||||
const dot = h("i");
|
||||
dot.style.background = s.color;
|
||||
item.prepend(dot);
|
||||
legend.append(item);
|
||||
}
|
||||
const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" });
|
||||
chart.setAttribute("aria-label", "최근 7 일 CPU · 메모리 · 디스크 사용률");
|
||||
for (const v of [0, 50, 100]) {
|
||||
chart.append(svg("line", { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v) }));
|
||||
const label = svg("text", { x: pad.l - 4, y: y(v) + 3, "text-anchor": "end" });
|
||||
label.textContent = `${v}`;
|
||||
chart.append(label);
|
||||
}
|
||||
// 날짜 눈금 — 자정마다
|
||||
const midnight = new Date(start);
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
for (let t = midnight.getTime(); t <= now; t += DAY_MS) {
|
||||
const d = new Date(t);
|
||||
const label = svg("text", { x: x(t), y: H - 6, "text-anchor": "middle" });
|
||||
label.textContent = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
chart.append(label);
|
||||
}
|
||||
const points = (data?.history ?? [])
|
||||
.filter((p) => p.timestamp)
|
||||
.map((p) => ({
|
||||
t: new Date(p.timestamp.endsWith("Z") ? p.timestamp : `${p.timestamp}Z`).getTime(),
|
||||
p,
|
||||
}))
|
||||
.filter(({ t }) => t >= start && t <= now)
|
||||
.sort((a, b) => a.t - b.t);
|
||||
for (const s of SERIES) {
|
||||
let d = "";
|
||||
let prev = -Infinity;
|
||||
for (const { t, p } of points) {
|
||||
const v = p[s.key];
|
||||
if (v == null) continue;
|
||||
d += `${t - prev > 3 * 3_600_000 ? "M" : "L"}${x(t).toFixed(1)} ${y(v).toFixed(1)} `;
|
||||
prev = t;
|
||||
chart.append(
|
||||
svg("circle", { cx: x(t).toFixed(1), cy: y(v).toFixed(1), r: 1.5, fill: s.color }),
|
||||
);
|
||||
}
|
||||
chart.append(svg("path", { d, fill: "none", stroke: s.color, "stroke-width": 1.5 }));
|
||||
}
|
||||
box.append(legend, chart);
|
||||
if (!points.length) box.append(h("p", "b01-pg-note", "7 일 안에 쌓인 기록이 없습니다."));
|
||||
return box;
|
||||
}
|
||||
|
||||
export function mountSysOverview(host: HTMLElement, _ctx: ViewCtx): void {
|
||||
loadPage(host, async (stage) => {
|
||||
const [res, companies, users] = await Promise.all([
|
||||
fetchSystemResources(7),
|
||||
fetchAllCompanies().catch(() => []),
|
||||
fetchAllUsers(null).catch(() => []),
|
||||
]);
|
||||
const now = res.current;
|
||||
const stats = h("div", "b01-sys-stats");
|
||||
stats.append(
|
||||
statCard("회사", companies.length),
|
||||
statCard("사용자", users.length),
|
||||
statCard("활성 프로젝트", now?.active_project_count ?? 0),
|
||||
statCard("활성 사용자", now?.active_user_count ?? 0),
|
||||
);
|
||||
const gauges = h("div", "b01-sys-stats");
|
||||
gauges.append(
|
||||
gauge("CPU", now?.cpu_usage_percent),
|
||||
gauge("메모리", now?.memory_usage_percent),
|
||||
gauge("디스크", now?.disk_usage_percent),
|
||||
);
|
||||
const page = pageFrame(
|
||||
stage,
|
||||
"개요",
|
||||
"서버 상태와 회사 · 사용자 · 프로젝트 수를 한눈에 봅니다.",
|
||||
);
|
||||
page.body.append(stats, gauges, buildWeekChart(res));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_SysUsers.ts
|
||||
* 시스템 관리 › 사용자(549-2) — 전체 사용자 표 · 거르기(회사 · 역할 · 찾기) · [+ 사용자] · 줄 끝 ⋯(수정 · 역할 변경 · 삭제).
|
||||
* 회사 거르기 = 서버가 그 회사 사람만 줌(전체는 최근 200 명) · 역할 · 찾기 = 받은 줄 안에서.
|
||||
* 주소 `&company=<id>&q=<글>` 로 미리 거른 채 열 수 있음(회사 상세 › 사람).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentQuery } from "../A00_Common/router";
|
||||
import {
|
||||
fetchAllCompanies,
|
||||
fetchAllUsers,
|
||||
type CompanyInfo,
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||
import {
|
||||
openAddMemberModal,
|
||||
openChangeRoleModal,
|
||||
openDeleteUserModal,
|
||||
openEditUserModal,
|
||||
} from "./B01_Dashboard_UI_Modals";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
emptyState,
|
||||
nameCell,
|
||||
pageFrame,
|
||||
primaryButton,
|
||||
rowMenu,
|
||||
type RowMenuItem,
|
||||
} from "./B01_Dashboard_UI_PageKit";
|
||||
import { el, loadPage, searchBox, selectBox, toolbar } from "./B01_Dashboard_UI_View_Parts";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
import { roleText, statusBadgeOf } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
|
||||
const SERVER_LIMIT = 200;
|
||||
|
||||
/** 역할 · 찾기(이름 · 이메일 · 회사 · 부서 · 직급) 거르기 — 순서는 받은 그대로 */
|
||||
export function filterUsers(users: DashboardUser[], role: string, query: string): DashboardUser[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return users.filter(
|
||||
(u) =>
|
||||
(!role || u.role === role) &&
|
||||
(!q ||
|
||||
[u.name, u.email, u.company_name, u.department, u.position]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS = [
|
||||
{ label: "이름" },
|
||||
{ label: "회사" },
|
||||
{ label: "직급" },
|
||||
{ label: "부서" },
|
||||
{ label: "전화" },
|
||||
{ label: "역할" },
|
||||
{ label: "상태" },
|
||||
{ label: "" },
|
||||
];
|
||||
|
||||
function menuOf(ctx: ViewCtx, u: DashboardUser): RowMenuItem[] {
|
||||
const items: RowMenuItem[] = [
|
||||
{ label: "정보 수정", onClick: () => openEditUserModal(ctx.me, u) },
|
||||
];
|
||||
if (canChangeRole(ctx.me, u))
|
||||
items.push({ label: "역할 변경", onClick: () => openChangeRoleModal(u) });
|
||||
// 회사에서 빼기 · 계정 삭제 — 처리 방식 고르는 창이 확인을 겸함 · 자기 자신은 안 보임
|
||||
if (canDeleteUser(ctx.me, u) && u.id !== ctx.me.id)
|
||||
items.push({ label: "삭제…", onClick: () => openDeleteUserModal(u) });
|
||||
return items;
|
||||
}
|
||||
|
||||
export function mountSysUsers(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const start = currentQuery();
|
||||
const state = {
|
||||
company: start.get("company") ?? "",
|
||||
role: "",
|
||||
q: (start.get("q") ?? "").toLowerCase(),
|
||||
};
|
||||
loadPage(host, async (stage) => {
|
||||
const companies: CompanyInfo[] = await fetchAllCompanies().catch(() => []);
|
||||
let users: DashboardUser[] = [];
|
||||
const body = el("div");
|
||||
const count = el("span", "b01-sys-count");
|
||||
const draw = (): void => {
|
||||
const shown = filterUsers(users, state.role, state.q);
|
||||
const capped = users.length >= SERVER_LIMIT && !state.company;
|
||||
count.textContent = `${shown.length} / ${users.length} 명${capped ? " · 최근 200 명까지 — 회사로 거르면 더 보임" : ""}`;
|
||||
body.replaceChildren(
|
||||
shown.length
|
||||
? dataTable(
|
||||
COLUMNS,
|
||||
shown.map((u) => {
|
||||
const st = statusBadgeOf(u.status);
|
||||
return [
|
||||
nameCell(u.name, u.email),
|
||||
u.company_name ?? "-",
|
||||
u.position ?? "-",
|
||||
u.department ?? "-",
|
||||
u.phone ?? "-",
|
||||
badge(roleText(u.role), u.role === "USER" ? "neutral" : "accent"),
|
||||
badge(statusLabel(u.status, st.text), st.tone),
|
||||
rowMenu(menuOf(ctx, u)),
|
||||
];
|
||||
}),
|
||||
)
|
||||
: emptyState({
|
||||
icon: "user",
|
||||
text: users.length ? "조건에 맞는 사용자가 없습니다." : "사용자가 없습니다.",
|
||||
}),
|
||||
);
|
||||
};
|
||||
const load = async (): Promise<void> => {
|
||||
users = await fetchAllUsers(state.company ? Number(state.company) : null);
|
||||
draw();
|
||||
};
|
||||
await load();
|
||||
|
||||
const company = selectBox(
|
||||
"회사",
|
||||
[["", "모든 회사"], ...companies.map((c): [string, string] => [String(c.id), c.name])],
|
||||
(v) => {
|
||||
state.company = v;
|
||||
void load();
|
||||
},
|
||||
);
|
||||
company.value = state.company;
|
||||
const role = selectBox(
|
||||
"역할",
|
||||
[
|
||||
["", "모든 역할"],
|
||||
["SYSTEM_ADMIN", "시스템 관리자"],
|
||||
["ADMIN", "회사 관리자"],
|
||||
["USER", "일반 사용자"],
|
||||
],
|
||||
(v) => {
|
||||
state.role = v;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
const search = searchBox("이름 · 이메일 · 부서 찾기", (v) => {
|
||||
state.q = v;
|
||||
draw();
|
||||
});
|
||||
search.value = state.q;
|
||||
const page = pageFrame(stage, "사용자", "모든 회사의 사용자를 보고 정보 · 역할을 고칩니다.");
|
||||
page.setAction(
|
||||
primaryButton("사용자", () =>
|
||||
openAddMemberModal(undefined, state.company ? Number(state.company) : ctx.companyId),
|
||||
),
|
||||
);
|
||||
page.body.append(toolbar(search, company, role, count), body);
|
||||
});
|
||||
}
|
||||
|
||||
/** 계정 상태 글 — 공용 배지 글에 없는 값(회사 없음)만 덧붙임 */
|
||||
function statusLabel(status: string, fallback: string): string {
|
||||
return status === "NO_COMPANY" ? "회사 없음" : fallback;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_UI_View_Temp.ts
|
||||
* 세부 페이지 「임시 보관함」(549-1) — 머리 + 오른쪽 위 [자료 올리기] + 보관함 본체(`UI_TempUpload`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
import { pageFrame, primaryButton } from "./B01_Dashboard_UI_PageKit";
|
||||
import { buildTempBox } from "./B01_Dashboard_UI_TempUpload";
|
||||
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
|
||||
|
||||
export function mountTemp(host: HTMLElement, ctx: ViewCtx): void {
|
||||
const page = pageFrame(
|
||||
host,
|
||||
L("B01_Temp_Section"),
|
||||
"프로젝트를 만들기 전에 측량 파일을 먼저 올려 두는 곳 · 새 프로젝트를 만들 때 불러 씀",
|
||||
);
|
||||
const box = buildTempBox(() => ctx.go("guide"));
|
||||
page.setAction(primaryButton("자료 올리기", box.openCreate, "upload"));
|
||||
page.body.append(box.root);
|
||||
}
|
||||
@@ -5,13 +5,26 @@
|
||||
* 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 { mountHome } from "./B01_Dashboard_UI_Home";
|
||||
import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav";
|
||||
import { mountAssigned } from "./B01_Dashboard_UI_View_Assigned";
|
||||
import { mountCompany } from "./B01_Dashboard_UI_View_Company";
|
||||
import { mountCompanyDefaults } from "./B01_Dashboard_UI_View_CompanyDefaults";
|
||||
import { mountPeople } from "./B01_Dashboard_UI_View_People";
|
||||
import { mountTemp } from "./B01_Dashboard_UI_View_Temp";
|
||||
import { mountProjectDetail } from "./B01_Dashboard_UI_ProjectDetail";
|
||||
import { mountProjectList } from "./B01_Dashboard_UI_ProjectList";
|
||||
import { mountMe } from "./B01_Dashboard_UI_View_Me";
|
||||
import { mountSysCompanies } from "./B01_Dashboard_UI_View_SysCompanies";
|
||||
import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults";
|
||||
import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs";
|
||||
import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview";
|
||||
import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers";
|
||||
|
||||
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
|
||||
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
|
||||
@@ -38,34 +51,30 @@ export interface View {
|
||||
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: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome },
|
||||
{ key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: mountProjectList },
|
||||
{ key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: mountProjectDetail },
|
||||
{ key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: mountAssigned },
|
||||
{ key: "temp", title: "임시 보관함", group: "work", roles: ALL, icon: "archive", mount: mountTemp },
|
||||
// 회사 관리
|
||||
{ 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: "people", title: "사람", group: "company", roles: ["ADMIN", "SYSTEM_ADMIN"], icon: "users", mount: mountPeople },
|
||||
{ key: "company", title: "회사 정보", group: "company", roles: ALL, icon: "building", mount: mountCompany },
|
||||
{ key: "company-defaults", title: "회사 설계 기본값", group: "company", roles: ["USER", "ADMIN", "FOREST"], icon: "adjustments", mount: mountCompanyDefaults },
|
||||
// 시스템 관리
|
||||
{ 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: "sys-overview", title: "개요", group: "system", roles: ["SYSTEM_ADMIN"], icon: "gauge", mount: mountSysOverview },
|
||||
{ key: "sys-companies", title: "회사", group: "system", roles: ["SYSTEM_ADMIN"], icon: "building", mount: mountSysCompanies },
|
||||
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers },
|
||||
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults },
|
||||
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs },
|
||||
// 도움
|
||||
{ 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") },
|
||||
{ key: "me", title: "내 정보", group: "hidden", roles: ALL, icon: "userCircle", mount: mountMe },
|
||||
];
|
||||
|
||||
/** 역할 가르기 — 옛 B01 갈래(시스템 관리자 · 회사 관리자 · 그 밖) + 산림청 */
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* =============================================================================
|
||||
* logic/B01_Dashboard_Home_Layout.ts
|
||||
* 홈 꼴 고르기 · 묶어 세기(PLAN 547-2) — 순수 함수 · 시험 `test_547_2_home_layout.py`
|
||||
* 진행 중 1 = one · 2~3 = few · 4~8 = list · 9 넘음 = many · 0 = empty
|
||||
* 고정 small = list(9 넘음 = many) · large = one(1) / few(2 넘음)
|
||||
* ========================================================================== */
|
||||
|
||||
export type HomeLayout = "empty" | "one" | "few" | "list" | "many";
|
||||
export type HomeFixed = "auto" | "small" | "large";
|
||||
|
||||
export const STALE_DAYS = 14;
|
||||
export const MANY_FROM = 9;
|
||||
|
||||
export function homeLayout(count: number, fixed: HomeFixed = "auto"): HomeLayout {
|
||||
if (count <= 0) return "empty";
|
||||
if (fixed === "large") return count === 1 ? "one" : "few";
|
||||
if (fixed === "small") return count >= MANY_FROM ? "many" : "list";
|
||||
if (count === 1) return "one";
|
||||
if (count <= 3) return "few";
|
||||
return count < MANY_FROM ? "list" : "many";
|
||||
}
|
||||
|
||||
interface StageLike {
|
||||
stage_index: number;
|
||||
stage_total: number;
|
||||
stale_days?: number | null;
|
||||
}
|
||||
|
||||
export const isStale = (card: StageLike): boolean => (card.stale_days ?? 0) >= STALE_DAYS;
|
||||
|
||||
/** 단계 분포 — 시작 전(첫 단계) · 설계 중 · 확정 대기(마지막 단계) */
|
||||
export function stageBuckets(cards: StageLike[]): { start: number; working: number; last: number } {
|
||||
const out = { start: 0, working: 0, last: 0 };
|
||||
for (const c of cards) {
|
||||
if (c.stage_index <= 0) out.start += 1;
|
||||
else if (c.stage_index >= c.stage_total - 1) out.last += 1;
|
||||
else out.working += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 묶어 세기 — 많은 차례 · 같으면 이름 차례 */
|
||||
export function countBy<T>(
|
||||
items: T[],
|
||||
key: (item: T) => string,
|
||||
): { label: string; count: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1);
|
||||
return [...counts]
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** 앞 limit 개 + 나머지 하나로 묶음(「그 밖 N 곳」) */
|
||||
export function topWithRest(
|
||||
rows: { label: string; count: number }[],
|
||||
limit: number,
|
||||
): { label: string; count: number; rest?: number }[] {
|
||||
if (rows.length <= limit) return rows;
|
||||
const rest = rows.slice(limit - 1);
|
||||
return [
|
||||
...rows.slice(0, limit - 1),
|
||||
{ label: "", count: rest.reduce((sum, r) => sum + r.count, 0), rest: rest.length },
|
||||
];
|
||||
}
|
||||
|
||||
/** 원 그래프 조각 — 둘레 100 기준 stroke-dasharray · stroke-dashoffset(12 시 시작 · 시계 방향) */
|
||||
export function donutArcs(values: number[]): { dash: string; offset: number }[] {
|
||||
const total = values.reduce((sum, v) => sum + v, 0);
|
||||
if (total <= 0) return [];
|
||||
let start = 0;
|
||||
return values.map((v) => {
|
||||
const len = (v / total) * 100;
|
||||
const arc = { dash: `${round2(len)} ${round2(100 - len)}`, offset: round2(25 - start) };
|
||||
start += len;
|
||||
return arc;
|
||||
});
|
||||
}
|
||||
|
||||
const round2 = (v: number): number => Math.round(v * 100) / 100;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B01_Dashboard_Logic_Pages.ts
|
||||
* 대시보드 세부 페이지 계산(549-1) — 화면 · fetch 없음 · Node 로 바로 시험.
|
||||
* 배지 글(역할 · 상태) · 보관함 남은 날 · 가입 요청 수.
|
||||
* ========================================================================== */
|
||||
|
||||
export type BadgeTone = "accent" | "neutral" | "success" | "warning" | "danger";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** 역할 → 한글 */
|
||||
export function roleText(role: string | null | undefined): string {
|
||||
if (role === "SYSTEM_ADMIN") return "시스템 관리자";
|
||||
if (role === "ADMIN") return "회사 관리자";
|
||||
if (role === "USER") return "일반 사용자";
|
||||
return role ?? "-";
|
||||
}
|
||||
|
||||
/** 상태 → 한글 · 배지 색 (모르는 값은 그대로 · 회색) */
|
||||
export function statusBadgeOf(status: string | null | undefined): {
|
||||
text: string;
|
||||
tone: BadgeTone;
|
||||
} {
|
||||
switch ((status ?? "").toUpperCase()) {
|
||||
case "ACTIVE":
|
||||
return { text: "활성", tone: "success" };
|
||||
case "INACTIVE":
|
||||
return { text: "비활성", tone: "neutral" };
|
||||
case "PENDING":
|
||||
return { text: "대기", tone: "warning" };
|
||||
case "APPROVED":
|
||||
return { text: "승인", tone: "success" };
|
||||
case "REJECTED":
|
||||
return { text: "거절", tone: "danger" };
|
||||
default:
|
||||
return { text: status || "-", tone: "neutral" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 남은 날 — 만료 시각까지 올림 일수 · 지난 것 0 · 만료 없음 null */
|
||||
export function daysLeft(
|
||||
expiresAt: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): number | null {
|
||||
if (!expiresAt) return null;
|
||||
const at = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(at)) return null;
|
||||
return Math.max(0, Math.ceil((at - now) / DAY_MS));
|
||||
}
|
||||
|
||||
/** 막대 채움 0~100 — 남은 날 ÷ 보관 기간 */
|
||||
export function leftPercent(left: number | null, retentionDays: number): number {
|
||||
if (left === null || retentionDays <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((left / retentionDays) * 100)));
|
||||
}
|
||||
|
||||
/** 7 일 안 만료 = 강조 */
|
||||
export const EXPIRY_WARN_DAYS = 7;
|
||||
export const isExpiringSoon = (left: number | null): boolean =>
|
||||
left !== null && left <= EXPIRY_WARN_DAYS;
|
||||
|
||||
/** 가입 요청 중 처리할 것(대기) 수 — 상태 값이 없으면 대기로 봄 */
|
||||
export function pendingCount(requests: { status?: string | null }[]): number {
|
||||
return requests.filter((r) => !r.status || r.status.toUpperCase() === "PENDING").length;
|
||||
}
|
||||
|
||||
/** 바이트 → 글 */
|
||||
export function bytesText(bytes: number): string {
|
||||
const gb = bytes / 1024 / 1024 / 1024;
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
const mb = bytes / 1024 / 1024;
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# 375-A 토큰 · 공용 부품 전역화 — 대시보드 테마를 프로젝트 안 화면까지
|
||||
|
||||
## ① 결론 다섯 줄
|
||||
|
||||
1. 토큰이 두 벌 — 옛 전역 `theme.css`(보라 Wiza · `:root` · CAD iframe 도 import) vs 대시보드 `--b01-*`(초록 · `.b01-shell` 안만 · 셸 안에서 `--color-*` 10개를 거꾸로 덮음) → **값 = 대시보드 값인 전역 의미 토큰 한 벌을 `theme.css` 에 두고 `--b01-*` 는 그 별칭으로 뒤집음** · 셸 덮어쓰기 10줄 · 셸 어두움 블록은 반드시 지움(남기면 순환 참조로 둘 다 무효).
|
||||
2. 보라를 직접 부르는 옛 이름 약 260곳(`royal-amethyst` 67 · `mist-violet` 51 · `deep-iris` 20 …)은 이름을 두고 새 토큰의 별칭으로 돌림 → 파일 하나로 B · D · E · M · CAD 가 함께 바뀜 · 시험 11개가 지키는 값(12/14/16px · 320 · 밝음/어두움/시스템 블록)은 그대로.
|
||||
3. 공용 부품은 거의 「제자리 다시 칠하기」 — 클래스 · `data-` 그대로(가이드 장면이 `ui-collapsible__title` 78 · `ui-sidebar-actions` 63 · `ui-btn--filled` 13 · `ui-btn--ghost` 11 씀) · PageKit 으로 갈아 끼울 곳 없음 · 탭만 공용 `.ui-tabs` 한 벌 새로(채운 단추 = 고른 탭 규칙 유지).
|
||||
4. 상세 틀은 값만 — 좌측 패널 = 대시보드 메뉴처럼 1px 선 · 그림자 없음 · 카드 10px · 손잡이만 강조색 · 위 얇은 머리줄은 `--app-header-h` 가 이미 19 파일에 깔려 있어 뒤 단계로 가능(날 `100dvh` 5곳만 손봄).
|
||||
5. 차례 = 0 토큰(1창 · 먼저) → 1 공용 틀 · 부품 · 아이콘(3창) → 2 페이지별(5창) → 3 머리줄(선택) → 가이드 재촬영 한 번 · 글꼴은 `--font-ui` 한 토큰으로 묶어 Pretendard 결정 뒤 한 줄 교체.
|
||||
|
||||
## ② 항목별 현황(근거)
|
||||
|
||||
### 1. 토큰 두 벌
|
||||
|
||||
| 구분 | 옛 전역 `ui_template/ui_template_theme.css` | 대시보드 `B01_Dashboard_UI_Style_Shell.css` |
|
||||
|---|---|---|
|
||||
| 자리 | `:root`(:12) · CAD `openwebcad/src/App.css:3` 가 import | `.b01-shell` 안만(:2-16) · 시험이 `:root` 금지 · `#1f7a4d` 글자 확인(`test_546_b01_shell.py:106-107`) |
|
||||
| 강조 | primary = deep-iris #26114a(:19,77) · accent · 초점 = royal-amethyst #3e0079(:21,79-80) | accent #1f7a4d · soft #eaf5ee · 위 글 #fff(:3-5) |
|
||||
| 바탕 · 카드 | bg = canvas #fff · surface = paper #f6f7fa · raised #fff(:69-71) | bg #f6f7f9 · card #fff(:6-7) |
|
||||
| 선 · 글 | mist #e6e2e3 · text = deep-iris · body #333 · secondary #615e6e · muted #9491a1(:29,73-76) | line #e6e8ec · text #1f2328 · muted #6b7280(:8-10) |
|
||||
| 올림 · 위험 | 올림 없음 · danger #c0392b(:40) | hover #f3f4f6 · alert #e5484d(:11-12) |
|
||||
| 둥긂 · 그림자 | 8 시그니처(:149) · 파랑 끼 여러 겹(:179-189) | 10(:13) · 메뉴만 `0 8px 24px rgba(16,24,40,.12)`(:186) · 카드는 선만 |
|
||||
| 어두움 | `:root[data-theme=dark]` 보라 끼(:303-342) + 시스템 자동(:345-381) | `:root[data-theme=dark] .b01-shell`(:37-48) 중립 회색 |
|
||||
|
||||
- 셸 안 덮어쓰기 — `--color-bg` · `surface` · `border` · `primary` · `text` 등 10개를 `--b01-*` 로(`Style_Shell.css:19-28`) → 옛 단추 · 카드 · 표가 셸 안에서만 초록 · 셸 밖은 보라.
|
||||
- 대시보드가 따로 막은 보라 — 단추 hover(`Style_Pages.css:632-638` ← `elements_styles.ts:48,55`) · 알약 색(`Style_Pages.css:431-457` ← `elements_styles.ts:190-194`).
|
||||
- 하드코딩 보라 — 고르기 화살표 SVG `%233e0079`(`elements_styles.ts:143`) · 가림막 `rgba(38,17,74,…)` 7곳(`elements_styles.ts:203,253` · `modal.css:8` · `overlay.css:541` · `app_shell.ts:469` · `B01_Dashboard_UI_Style.css:275` · `M01_MasterData_UI_Logic_Style.css:340`).
|
||||
- 흠 — 정의 없는 `--color-muted`(`workflow_layout.css:102`) · `--color-surface-sunken`(`context_menu.css:42,56` · `Style_Projects.css:11`) · 안 쓰는 것 = 그라디언트 3 · `--shadow-feature` · `.ui-wf` 규칙(`elements_styles.ts:276-302` · `createWorkflowShell` 부름 0).
|
||||
- 사용 수(theme.css 밖) — 옛 이름: canvas 84 · royal-amethyst 67 · mist-violet 51 · slate 24 · deep-iris 20 · paper 17 · plum-velvet 9 / 별칭: border 399 · surface 190 · danger 188 · text 144 · accent 111 · primary 83.
|
||||
- 값 지키는 시험 — `--text-body-sm: 14px`(164 · 228_1) · `--text-body: 16px` · `--text-caption: 12px`(228_1) · `--wf-left-panel-width: 320px`(173_2) · 어두움 블록 안 canvas · mist · accent · shadow-lg(185_3 · 218_1) · 시스템 블록 안 success · danger(215_1) · warning · success `#` 두 번(177_1 · 198_1 · 247_1) · `--map-route-locked` 개수(391).
|
||||
- 캔버스가 `--color-primary` 를 읽어 선을 그림 — `B05_Profile_UI_RouteEdit_Recommend_Map.ts:132` · `D03_PlanAnalysis_UI_Map.ts:251` · `E07_StabArea_UI_Map.ts:171` → 초록이 되면 숲 위성 위에서 묻힘.
|
||||
|
||||
### 2. 공용 부품
|
||||
|
||||
| 부품 | 지금(근거) | 대시보드 짝 | 권함 |
|
||||
|---|---|---|---|
|
||||
| 단추 6꼴 | `.ui-btn` filled · ghost · pill · danger · danger-ghost · glass(`elements_styles.ts:17-94`) · hover 보라 고정(:48,55,63) | 같은 `.ui-btn` + hover 덮음(`Style_Pages.css:632-638`) | 제자리 — hover 를 `--color-primary-hover` · `--color-accent-soft` 로 · 꼴 이름 그대로 |
|
||||
| 입력 · 고르기 | `.ui-input` · `.ui-select` 42px(:97-162) · 화살표 보라(:143) · 좌측 양식 26px(`overlay.css:419-488`) | 같은 부품 · `b01-sys-input` 32px(`Style_Pages.css:476-489`) | 제자리 — 화살표 회색 · 초점 = 토큰 · 높이는 정할 거리 |
|
||||
| 카드 · 구역 | `.ui-card` 여백 24 · 그림자 · 제목 plum-velvet · font-display(:165-178) | `.b01-pg-card` 선 + 10px · 그림자 없음(`Style_Pages.css:43-47`) | 제자리 — `--card-padding` 16 · 그림자 0 · 제목 글색 |
|
||||
| 접는 판 | `createPanelCard`(`collapsible.ts:18-24`) · 캐럿 ▾▸ 1.7em 글자(`theme.css:450-471`) · ⚙ 글자(`collapsible.ts:32`) | 셰브런 아이콘(`icons.ts:56`) | 제자리 — 캐럿 = CSS mask 셰브런(TS 안 고침) · ⚙ → `icon("settings")` |
|
||||
| 표 | 데이터표 28px · 세로선 · 머리 · hover 강조색 섞음(`data_table.css:6-29,54-65`) · 일반표 위아래 12 · 16(`general_layout.css:92-102`) · 손표(M02 16 · M01 11 · E03 5) | `dataTable` 40px · 세로선 없음 · 머리 12px 회색(`PageKit.ts:182` · `Style_Pages.css:51-93`) | 데이터표 = 밀도 그대로 · 머리 `--color-surface` · hover `--color-hover` / 일반표 = PageKit 꼴을 `general_layout.css` 로 옮김(M02 가 클래스 직접 씀 — 교체 불가) |
|
||||
| 탭 7꼴 | B08 · B09 위 탭 = filled/ghost 단추(`B08_Quantity_UI_Page.ts:120-135` · `B09_Estimation_UI_TabBar.ts:23-40` · css `B08…Page.css:14` · `B09…Page.css:21`) · `.ui-subtabs`(`general_layout.css:59-71`) · `app-settings__tabs`(`app_settings.css:2`) · `m02-sod__tabs`(`M02_SO_Style.css:373`) · 문서 탭 `cad-tabs`(`cad_host_tabs.css:3-40`) · `ss-tabs`(`spreadsheet_toolbar.css:190-233`) | 밑줄 탭 `tabBar`(`PageKit.ts:224-265` · `Style_Pages.css:185-223`) | 공용 `.ui-tabs` 새 규칙(밑줄 꼴 · `.ui-btn--filled` = 고른 탭) + 탭 줄 4곳에 클래스 한 줄 · `.ui-subtabs` = 작은 알약 꼴 · 문서 탭 둘은 꼴 그대로(토큰만) |
|
||||
| 알약 · 배지 | `.ui-tag` 5색 · 여백 4/16 · mist-violet 바탕(`elements_styles.ts:181-194`) | `badge()`(`PageKit.ts:49`) + 색 규칙(`Style_Pages.css:123-128,431-457`) | B01 색 규칙을 전역으로 올림 · 여백 2/8 |
|
||||
| 모달 · 확인창 | `openModal`(`modal.ts:28-50`) · 12px · 가림막 보라 · 제목 = 전역 h3 24px(`modal.css:1-66` · `theme.css:531`) · `.ui-confirm`(`elements_styles.ts:247-274`) | 같은 `openModal`(B01 5 파일) | 제자리 — 가림막 토큰 · 제목 16px 600 |
|
||||
| 토스트 | 단색 4꼴 · info = royal-amethyst(`elements_styles.ts:221-244`) | 같은 `showToast` | 토큰만(info = `--color-info`) |
|
||||
| 툴팁 | `.ui-hover-info` 2px · 12px(`hover_info.css:6-21`) + `title=` | 없음 | 둥긂 6 · 그림자 `--shadow-lg` |
|
||||
| ⋯ · 오른쪽 메뉴 | `.ui-ctx-menu`(`context_menu.css:12-62`) | `rowMenu`(`PageKit.ts:100-142` · `Style_Pages.css:131-182`) | 제자리 — `--color-surface-sunken` 정의로 hover 해결 · 줄 둥긂 6 |
|
||||
| 세그먼트 | 공용 없음 · 페이지마다 filled/ghost 짝 · `.b06-design__seg`(`B06…Cross_Chart.css:250`) | 셋 따로(`b01-home__seg` Home.css:44 · `b01-proj-seg` Projects.css:88 · `b01-sys-seg` Pages.css:522-541) | 새로 안 만듦 — 쓸 페이지가 생길 때 `b01-sys-seg` 를 `.ui-seg` 로 올림 |
|
||||
| 아래 단추 줄 | `.ui-sidebar-actions`(`overlay.css:317-385`) · B05 · B06 dock 도 같은 클래스(`design_dock.ts:56`) | 없음 | 토큰만 · 바탕 = 패널 바탕 |
|
||||
| [?][<][>] | ghost 단추 글자 `?` `<` `>`(`help.ts:41,49,322-345,354-374`) · 정사각(`help.css:2-11`) | 머리 아이콘 단추 32 · 선 · 회색(`Style_Shell.css:107-125`) | `help.css` 에서 같은 꼴로 · 글자 유지(시험 = 클래스 · `data-step-nav` 만 봄 `helper_280_2_step_nav.cjs:228`) |
|
||||
|
||||
### 3. 상세 틀(좌측 패널 · 본문 · 아래 줄 · 덮개)
|
||||
|
||||
- 좌측 패널 = `.ui-workflow-overlay__panel--title`(`overlay.css:21-40`) — 흰 바탕 · `--shadow-lg`(:16) · 오른쪽 테두리 = 강조 파랑 75%(:33-37) · 손잡이 파랑 테두리 · 접힘 시 파랑 칠(:156-187) → 대시보드 메뉴(`Style_Shell.css:248-256` 흰 판 · 1px 선 · 그림자 없음)와 다름.
|
||||
- 바꿀 값(배치 · 동작 그대로) — 판 그림자 0 · 오른쪽 선 `--color-border` · 손잡이 = `--panel-handle-border: var(--color-accent)`(보임 지시 2026-08-04 유지) · 머리 제목 16 → 14px 600 · 프로젝트 이름 12px 회색(:116-143) · 카드 `.ui-sidebar-card` 둥긂 10(:407-411) · 강한 외곽선 `.ui-sidebar-section` 은 섞기 대신 `--color-border-strong`(:392-396 · 흐리다는 보고 두 번 — 너무 옅게 안 함) · 줄 고름 = accent-soft(:519-526 · 별칭으로 자동) · 덮개 = `--color-scrim`(:541).
|
||||
- 단계 목록(☰ 서랍 안 · `workflow_layout.css:7-108`) — 고름 = mist-violet · accent(:83-86) → 별칭으로 대시보드 메뉴 고름(`Style_Shell.css:275-279`)과 같은 꼴이 됨 · `--color-muted` 흠 고침(:102).
|
||||
- 본문 `__main` = 흰 canvas(`workflow_layout.css:132-138`) — 지도 · 3D · CAD 라 흰 바탕 유지.
|
||||
- ☰ 서랍 CSS(`app_shell.ts:445-581` 문자열) — 대시보드 메뉴 줄 꼴(36px 줄 · 8px 둥긂 · 고름 accent-soft)로 맞춤.
|
||||
- 위 얇은 머리줄(프로젝트 이름 + 단계 막대) — 가능 · 뒤 단계: 지금 B · M 은 머리 0 + ☰ 만 띄움(`device_context.css:89-98` · `app_shell.ts:534-556`) · 높이 계산이 `--app-header-h` 하나(19 파일)라 `html.is-bm-route { --app-header-h: 48px }` 로 판 · 덮개 · 본문이 함께 내려감 · 손볼 곳 = 날 `100dvh` 5곳(`design_page.css:51,84` · `E04…Style.css:8` · `E06…Style.css:15` · `E07…Style.css:8`) · ☰ 들여쓰기 변수(`--app-burger-inset` · `--wf-burger-pad` `workflow_layout.css:143-155`) 0 · 단계 막대는 `createStepBar` 의 `compact` 꼴(`workflow_layout.ts:77`) 재사용 · 패널 머리의 프로젝트 이름(`overlay.ts:148`)과 겹침 정리 · 가이드 그림 전부 바뀜 → 마지막.
|
||||
|
||||
### 4. 글꼴
|
||||
|
||||
- 지금 — `body { font-family: var(--font-body) }`(`theme.css:504`) · 첫 둘 Inter · Noto Sans KR 은 파일 없음(@font-face 0) → 깔린 PC 만 그 글꼴 · 아니면 Segoe UI + 맑은 고딕 · 대시보드도 글꼴 지정 없음(상속) = 「시스템 글꼴」 · 제목은 `--font-display`(Britti Sans 등 · 역시 없음 · :92-94 · h1~h6 :519 · 쓰는 곳 7).
|
||||
- Pretendard = SIL OFL 1.1(웹 내장 · 상용 가능 · 글꼴만 팔기 금지) · 허용 목록(BSD · MIT · Apache) 밖 → 결정 대기(`_조사_366-B_대시보드_SaaS.md:95,107` · `PLAN.md:110`).
|
||||
- 한 줄 교체 설계 — `--font-ui` 하나 · `--font-body` · `--font-display` = `var(--font-ui)` · 코드의 font-family 는 이미 전부 var(인쇄 HTML `spreadsheet_print_html.ts:200` 은 문서 글꼴이라 제외) · 채택 시 `theme.css` 안 `@font-face` 한 블록(상대 경로 woff2) + `--font-ui` 첫 칸 → CAD 도 theme.css 를 import 해 함께 따라옴(CAD 빌드 1번) · 숫자 칸 = `tabular-nums`(`data_table.css:70` 이미).
|
||||
|
||||
### 5. 아이콘(이모지 · 글자 기호 — git 추적 .ts/.css · 주석 · 글 화살표 → ← 뺌)
|
||||
|
||||
| 폴더 | 이모지 | 도형 · 화살표 | 대표 자리 |
|
||||
|---|---|---|---|
|
||||
| A00_Common | 29 | 13 | 단계 📍🧭✅ · 🪨📐🧊📄⛰️🗺️(`group_workflow.ts:30,42`) · ☰ ☀ ☾(`app_shell.ts:98,166,221`) · ↶↷(`spreadsheet_toolbar.ts:147,149`) |
|
||||
| B06 | 11 | 41 | ▲▼◀▶ 미세 이동 · ⚠ · ↺ |
|
||||
| B05 | 6 | 28 | ✕(`RouteEdit_Chrome.ts:226` · `RouteEdit_Label.ts:175`) · ▲▼ · ⇧⇩ |
|
||||
| ui_template | 8 | 11 | 📁🗺️🛣️📐∑🏗️📄(`workflow_layout.ts:65`) · 🏠(:88) · ⚙(`collapsible.ts:32`) · ▾▸(`theme.css:460,470`) |
|
||||
| 글 묶음(locale) | 18 | 15 | `locale_b5.ts` 14 |
|
||||
| M02 | 13 | 16 | ×(`Hopyo_Ledger.ts:200` · `M02_DF_FuncPanel.ts:339`) · 🔒 |
|
||||
| B09 · B08 | 2 · 0 | 10 · 1 | ×(`B09_Estimation_UI_Popup.ts:157` · `B08_Quantity_UI_Milestone_LegModal.ts:82`) |
|
||||
| B07 · B03 · M01 · E03 · D02 · D04 · E06 | 4 · 1 · 1 · 2 · 1 · 0 · 0 | 0 · 8 · 3 · 1 · 0 · 1 · 1 | ⚙ · 📍 단추(`D02_PlanBase_UI_Page.ts:164`) · ×(`D04…Page.ts:196` · `E06…Joints.ts:150`) |
|
||||
|
||||
- 바꾸는 길 — `ui_template_icons.ts`(Tabler MIT · 21개 :30-64)에 약 20개 한 줄씩 더함(map · road · ruler · sum · crane · mapPin · compass · circleCheck · mountain · cube · settings · x · chevronLeft/Right · menu · undo/redo · alert · lock · help) · `PageKit.ts:14-22` 의 6개도 이 파일로 옮겨 중복 없앰.
|
||||
- 단계 아이콘 — `createStepBar` 의 `icon.textContent = iconText`(`workflow_layout.ts:102-107`) → 아이콘 이름이면 `icon(name, 16)` · 배열 셋(`workflow_layout.ts:65` · `group_workflow.ts:30,42`)을 이름으로.
|
||||
- 단추 — `createButton({ label, iconStart: icon(…, 16) })`(`elements.ts:39-52` · PageKit 과 같은 길) · 아이콘만 단추 = label "" + title · aria-label.
|
||||
- 함정 — `.ui-icon` 규칙이 대시보드 CSS 에만 있음(`Style_Shell.css:132-136` · 대시보드를 안 거친 화면엔 없음) → 전역(`elements_styles.ts`)으로 옮김.
|
||||
|
||||
### 6. 어두움
|
||||
|
||||
- 켜는 법은 이미 하나 — 둘 다 `<html data-theme>` + localStorage `frd_theme`(`app_shell.ts:26-42` · `B01_Dashboard_UI_Shell.ts:53-57`) · CAD 도 같은 열쇠(`openwebcad/src/theme.ts:13-36`) · 단 바꾸는 함수가 두 벌 · 단추 셋(`app_shell.ts:97-98,221` · `B01…Shell.ts:127-134`).
|
||||
- 값이 세 벌 — 전역 어두움(`theme.css:303-342`) · 시스템 자동(:345-381 · `--color-warning` 빠짐 · `index.html:2` `data-theme="light"` 고정이라 사실상 안 쓰임) · 셸 어두움(`Style_Shell.css:37-48`).
|
||||
- 하나로 — 값은 theme.css 두 블록에만(시스템 블록은 시험 215_1 때문에 두되 같은 값) · 셸 블록 지움 · 바꾸는 함수는 `app_shell.ts` 하나를 내보내 B01 이 부름.
|
||||
|
||||
## ③ 권하는 전역화 설계
|
||||
|
||||
### 토큰 표(`theme.css` 한 곳 · 「대시보드 별칭」 = `.b01-shell` 안 새 정의)
|
||||
|
||||
| 토큰 | 밝음 | 어두움 | 대시보드 별칭 · 비고 |
|
||||
|---|---|---|---|
|
||||
| `--font-ui`(새) | 지금 `--font-body` 줄 그대로 | 같음 | `--font-body` · `--font-display` = `var(--font-ui)` |
|
||||
| `--color-accent` | #1f7a4d | #3fae75 | `--b01-accent` · focus-ring · panel-handle-border 도 이것 |
|
||||
| `--color-accent-soft`(새) | #eaf5ee | #173326 | `--b01-accent-soft` |
|
||||
| `--color-primary` · `-text` | var(accent) · #ffffff | var(accent) · #0e1512 | `--b01-accent-text` |
|
||||
| `--color-primary-hover`(새) | `color-mix(in srgb, var(--color-primary) 88%, #000)` | `… 85%, #fff` | `Style_Pages.css:632-638` 대체 |
|
||||
| `--color-bg` | #f6f7f9 | #121417 | `--b01-bg` |
|
||||
| `--color-surface`(= paper) | #f6f7f9 | #121417 | 얼룩줄 · 표 머리 · 판 바탕 |
|
||||
| `--color-surface-raised` · `--color-canvas` | #ffffff | #1b1e23 | `--b01-card` |
|
||||
| `--color-hover`(새) · `--color-surface-sunken`(새 · 별칭) | #f3f4f6 | #242830 | `--b01-hover` |
|
||||
| `--color-border`(= mist) | #e6e8ec | #2c3038 | `--b01-line` |
|
||||
| `--color-border-strong`(새 · 제안값) | #cfd4db | #3b414b | 좌측 카드 외곽 · 표 머리 밑줄 |
|
||||
| `--color-text` · `-body` | #1f2328 | #e6e8eb | `--b01-text` |
|
||||
| `--color-text-secondary` · `-muted` | #6b7280 | #9aa1ab | `--b01-muted`(셸이 지금도 둘을 한 값으로 씀) |
|
||||
| `--color-success` | #1f8a4c | #3fbb6f | 그대로 |
|
||||
| `--color-warning` | #b45309 | #f5a524 | 대시보드 알약 값(`Style_Pages.css:449,456`) |
|
||||
| `--color-danger` | #e5484d | #f06b6f | `--b01-alert` · 대비는 정할 거리 |
|
||||
| `--color-info` | #3b82c4 | #6aa6e0(제안) | `--b01-s2` |
|
||||
| `--color-scrim`(새) | rgba(16,24,40,.40) | rgba(0,0,0,.55) | 가림막 7곳 |
|
||||
| `--radius-cards` | 10px | — | `--b01-radius` · 단추 · 입력 8 그대로 |
|
||||
| `--shadow-sm` · `-lg` | 0 1px 2px rgba(16,24,40,.06) · 0 8px 24px rgba(16,24,40,.12) | 지금 값 | 카드는 선만 |
|
||||
| `--card-padding` | 16px | — | 지금 24 |
|
||||
|
||||
- 옛 이름 — deep-iris · plum-velvet → `var(--color-text)` · royal-amethyst · lavender-wash → `var(--color-accent)` · mist-violet → `var(--color-accent-soft)` · 중립(canvas · paper · mist · smoke · ash · slate · charcoal · carbon · ink)은 새 값을 직접 둠(시험이 어두움 블록 안 canvas · mist 를 봄) · 의미 토큰이 중립을 부르는 지금 방향은 둠 · 「옛 이름 — 새 코드 금지」 주석 · twilight-beam · 그라디언트 3 · shadow-feature 지움.
|
||||
- 대시보드 — `.b01-shell { --b01-accent: var(--color-accent); … --b01-radius: var(--radius-cards); }` · 배치 값(head-h 48 · nav-w 210 · rail-w 56)은 그대로 · `Style_Shell.css:19-28` · `:37-48` 지움 · `test_546_b01_shell.py:106-107` 을 「theme.css 에 `--color-accent: #1f7a4d;` · 셸에 별칭」으로 고침(까닭은 커밋 글에).
|
||||
|
||||
### 부품 · 틀 표(클래스 이름 그대로 · 값만)
|
||||
|
||||
| 파일 | 바꿀 것 |
|
||||
|---|---|
|
||||
| `ui_template_elements_styles.ts` | 단추 hover · 고르기 화살표 회색 · 카드(16 · 그림자 0) · 알약 5색(B01 규칙 이전) · 토스트 info · 가림막 · `.ui-icon` 이전 · `.ui-wf` 지움 |
|
||||
| `ui_template_theme.css` | 토큰 표 · 캐럿 ▾▸ → mask 셰브런(:450-471) |
|
||||
| `ui_template_overlay.css` | 판 그림자 · 오른쪽 선 · 머리 글 크기 · 카드 둥긂 · 강한 외곽선 · 덮개 |
|
||||
| `ui_template_workflow_layout.css` · `ui_template_help.css` | `--color-muted` 흠 · [?][<][>] = 대시보드 아이콘 단추 꼴 |
|
||||
| `ui_template_general_layout.css` | 제목 32 → 20px 700 · 부제 13 · 바깥 여백 20/24/32(B02 · D01 · E01 · B03 꼴이 대시보드 페이지 머리와 같아짐) · 일반표 = PageKit 표 꼴 · `.ui-tabs` 새 규칙 · `.ui-subtabs` 알약 꼴 |
|
||||
| `ui_template_modal.css` · `_data_table.css` · `_context_menu.css` · `_hover_info.css` | 제목 16 · 가림막 / 머리 · hover 중립 / hover · 둥긂 / 둥긂 · 그림자 |
|
||||
|
||||
## ④ 단계 · 창 나눔(같은 단계 안 파일 겹침 0 · 단계는 앞 단계 dev 합류 뒤 시작)
|
||||
|
||||
| 단계 | 창 | 이 창만 고치는 파일 | 검증 |
|
||||
|---|---|---|---|
|
||||
| 0 토큰 | W0 | `ui_template_theme.css` · `ui_template_elements_styles.ts` · `B01_Dashboard_UI_Style_Shell.css` · `B01_Dashboard_UI_Style_Pages.css`(:430-465 · 631-638만) · `resources/tester/test_546_b01_shell.py` | `npm run typecheck` · 테마 시험 11(164 · 173_2 · 177_1 · 185_3 · 198_1 · 212_3 · 215_1 · 218_1 · 228_1 · 247_1 · 391) + 546 · 549 · 대시보드 홈 · 목록 · 시스템 밝음/어두움 전후 그림이 같음 · B05 · B08 · D03 한 장씩 · `openwebcad` `npm run build` 뒤 B07 한 장 |
|
||||
| 1 틀 | W1 | `ui_template_overlay.css` · `ui_template_workflow_layout.css` · `ui_template_help.css` · `A00_Common/design/design_dock.css` | 280_2 · 538 · 122 · 302_1 · design_view_parity · B04 · B05 · B08 · E02 · M02 패널 열림/접힘 · 폭 860 · 560 그림 |
|
||||
| 1 부품 | W2 | `ui_template_general_layout.css` · `_modal.css` · `_data_table.css` · `_context_menu.css` · `_hover_info.css` · `_group_cards.css` · `_tree.css` · `_side_panels.css` | 228_1 · 199_1 · 201_1 · 243_1 · 246_2 · 185_3 · B02 양식 · B08 표 · M02 표 · 모달 · 오른쪽 메뉴 그림 |
|
||||
| 1 아이콘 · 셸 | W3 | `ui_template_icons.ts` · `ui_template_workflow_layout.ts` · `ui_template_collapsible.ts` · `A00_Common/group_workflow.ts` · `A00_Common/app_shell.ts` · `B01_Dashboard_UI_Shell.ts` · `B01_Dashboard_UI_PageKit.ts` | typecheck · 546 · 549 · 280_2 · ☰ 서랍 · 단계 목록 · 대시보드 머리 그림 · 아이콘 파일은 먼저 push(2단계가 씀) |
|
||||
| 2 페이지 | P1 | `B02_*` · `B03_*` · `B04_*` · `B07_*`(openwebcad 은 빌드만) | 자기 페이지 시험 · 자기 포트 그림 |
|
||||
| 2 페이지 | P2 | `A00_Common/design/` · `B05_*` · `B06_*` | 〃 · `test_design_view_parity.py` |
|
||||
| 2 페이지 | P3 | `B08_*` · `B09_*` · `A00_Common/app_settings.*` | 〃 · 122 · 156_1 · 185_1 · 200_2 · 235_1 |
|
||||
| 2 페이지 | P4 | `D01~D04_*` · `E01~E07_*` | 〃 |
|
||||
| 2 페이지 | P5 | `M01_*` · `M02_*` · `A00_Common/spreadsheet/` | 〃 · `test_cad_host_tabs.py` · M02 시험 |
|
||||
| 3 머리줄(선택) | H1 | `app_shell.ts` · `device_context.css` · `overlay.css`(bm 규칙) · `workflow_layout.css` · `general_layout.css` · `design_page.css` · E04 · E06 · E07 `*_UI_Style.css` | 전 상세 페이지 높이 · 넘침 0 · 터치 모드 그림 |
|
||||
| 끝 | 1창 | `A05_EduDetail/guide/*` 그림만 | `A05_EduDetail_Guide_Capture.py --page <X>` 20 페이지 · 장면 셀렉터 깨짐 0 |
|
||||
|
||||
- 2단계 공통 할 일 — 탭 줄에 `ui-tabs` 한 줄(B08 `Page.ts:120` · B09 `TabBar.ts:37` · `app_settings` · `m02-sod__tabs`) · ×✕📍↶↷⚙ → `icon()` · 겉 틀 hex → 토큰(캔버스 · 3D · 지도 그림 색 제외) · `--color-primary` 로 선 그리는 지도 3곳 → 지도 전용 토큰.
|
||||
- 공용 파일 변경 = 알릴 일 — 0 · 1단계 push 때 쪽지 한 줄.
|
||||
|
||||
## ⑤ 정할 거리
|
||||
|
||||
- 공개 쪽(A01~A08)도 초록 · 회색 바탕을 함께 받음(theme.css 전역) — 기본 = 같이 바뀜 · 막으려면 `html.is-bm-route` 범위(단 CAD iframe 은 그 클래스 없음).
|
||||
- 위험색 #e5484d 글 대비 3.9:1(작은 글 AA 미달) — 대시보드 값 통일 vs 글 전용만 #c0392b 유지.
|
||||
- 좌측 패널 — 흰 판(대시보드 메뉴 꼴) vs 회색 판 + 흰 카드.
|
||||
- 밀도 — 입력 높이 42 유지 vs 36(대시보드 양식도 같이 바뀜).
|
||||
- 지도 위 계획선 색 — 지금 보라 primary · 초록이면 숲에 묻힘 → 지도 전용 색 고름.
|
||||
- 시스템 어두움 따름(`index.html:2` 고정 풀기) 여부.
|
||||
- 글꼴 — Pretendard(OFL) 허용 여부 · 글꼴 줄을 시스템 먼저로 고정할지(Noto Sans KR 깔린 PC 와 아닌 PC 의 차이).
|
||||
- 구분 색(임도 · 예정노선 · 안정성 `Style_Projects.css:17-19`)을 전역 토큰으로 올릴지.
|
||||
- PageKit(⋯ 메뉴 · 빈 상태 · 설정 줄)을 `ui_template` 로 올릴지 — 페이지가 쓸 때만.
|
||||
- 위 머리줄(3단계) 할지 · 가이드 그림 300장 재촬영 시점(마지막 한 번 권함).
|
||||
@@ -0,0 +1,136 @@
|
||||
# 375-B 페이지별 손볼 양 · 위험 목록 — 대시보드 테마를 프로젝트 안 화면까지
|
||||
|
||||
세는 법 — 주석 뺀 원본(`node_modules` · `dist` · `guide` 뺌). 색 = CSS 날값(var 대체값 · `--변수:` 정의 따로) · TS 는 hex+rgb(그림 상수 섞임). 글자 = `font-size: 숫자` · `font: …px`. 기호 = 따옴표 안 그림 이모지 / 화살표 · 도형 기호. 자체 부품 = 페이지 CSS 의 btn · tab · table · badge · chip · pill · tag · toolbar 선택자(ui- 뺌) / `ui-btn` 없는 날 `<button>`.
|
||||
|
||||
## ① 결론 다섯 줄
|
||||
|
||||
1. 페이지 대부분은 이미 토큰만 씀 — CSS 날 색은 B06 33 · B05 26 · M02 14 · B03 8 · E06 7 뿐 · D01~D04 · E01 · E02 · E05 · B02 는 거의 0 → 큰 덩어리는 B05·B06 · M02 · CAD 앱 · 스프레드시트(E05) 넷.
|
||||
2. 가장 큰 위험 = 그림이 UI 토큰을 씀 — SVG 선 · 면이 `--color-royal-amethyst` · `accent` · `primary` · `chart-*` 를 직접 씀(B06 55줄 · B05 20 · B08 19 · M02 27 · E03 66 · E06 15) · CAD 바탕 = `--color-canvas` · 격자 = `--color-mist` · 3D 바탕 = `--color-surface-raised` → 375-A 안(옛 보라 이름을 새 강조색 별칭으로)이면 B06 설계선이 초록이 되어 원지반선(chart-1 #34c38f)과 겹침 → **그림 토큰 고정이 0단계 선행 조건**.
|
||||
3. 출력은 UI CSS 를 안 읽음 — B09 표 PDF(서버 `PRINT_CSS`) · 도면 PDF(`paint_color` 파이썬 짝) · E05 PDF(`spreadsheet_print_html.ts` `BASE_CSS`) · E05 그림(서버 SVG) 모두 자체 색 → 색 누수 없음 · 단 B09 표 PDF 는 `.ui-data-table__table` 클래스 이름에, 도면 PDF 는 「밝은 바탕 = 흰 종이」 가정에 묶임.
|
||||
4. 시험은 값보다 「var 를 쓰는지」를 봄 — 값 단정 11줄(글자 16/14/12 · 320 · 지도 · 발자국 색 · warning/success `#` 정의) · 기호 단정(→ 24파일 · ← 6 · ✓ 5 · ▾ 4 · ▲▼◀▶ 3) · 클래스 이름 270 파일 · 가이드 표시 선택자 704(페이지 클래스 472) → **클래스 이름 · 기호 글자 · DOM 단계는 그대로, 값만 바꿈**이 원칙.
|
||||
5. 순서 = 0단계 공용(한 창 · 그림 토큰 고정 포함) → 5창 병행(B05·B06 / B07+CAD / B08·B09 / E+스프레드시트 / B02~B04·D) → M02 · A군은 따로 결정 · 창마다 typecheck · 대상 시험 · tmp 전후 촬영 · 그림 · PDF 불변 확인.
|
||||
|
||||
## ② 페이지별 표
|
||||
|
||||
| 폴더 | CSS 파일/줄 | 색 CSS 날(대체·정의) · TS | 글자 CSS·TS | 이모지/기호 | 자체 부품 · 날 단추 | 주요 자리 | 크기 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| B02 | 1/49 | 0 · 0 | 0 | 0/0 | 0 · 0 | 공용 부품만 | 소 |
|
||||
| B03 | 3/796 | 8(63·0) · 0 | 0 | 1/6(▤◈●◇□▧) | 1 · 3 | `B03_FileInput_UI_Style_Card.css` 40 · 보라 이름 23 | 중 |
|
||||
| B04 | 2/850 | 0(1·0) · 75 | 0 · 3 | 0/1 | 7 · 13 | `_UI_TerrainViewer_Chrome.ts:141-156` 범례 칩 인라인 색(UI) · `_UI_Watershed.ts:28-34` 지도 | 중 |
|
||||
| B05 | 10/3668 | 26(54·9) · 74 + 0x 33 | 19 · 0(캔버스 7) | 4/27(▲▼▾↶↷ ⚠✕) | 29 · 22 | `_UI_Style_Table.css` 22 · `_Style_Structures.css` 18 · `_RouteEdit_Render.ts` 26 | 대 |
|
||||
| B06 | 6/2336 | 33(8·0) · 3 | 21 | 10/42(▲▼◀▶↺ ⚠8) | 26 · 12 | `_UI_Style_Cross_Areas.css` 26 · 그림 선 55줄 | 대 |
|
||||
| A00 design(B05·B06 공용) | 2/197 | 0 | 0 | — | 0 | `design_page.css` · `design_dock.css` | B05·B06 몫 |
|
||||
| B07 페이지 | 1/550 | 0(1·0) · 3 | 7 | 4/1(⚙) | 8 · 2 | `B07_DesignDetail_UI_Style.css` | 중 |
|
||||
| B07 CAD 앱 `openwebcad/src` | 4/944 | 0(1·0) · 100(도면 상수) | 42 | 42/62(⚙✎💾🖼🔒⛶🔍) | 5 · 34(React) | `App.css:3` theme import · `App.consts.ts:25-236` | 대 |
|
||||
| B08 | 4/738 | 1 · 0 | 4 | 0/10(→) | 9 · 0 | `B08_Quantity_UI_Milestone.css` 그림 19줄 | 중 |
|
||||
| B09(+draft 인쇄) | 3/478 | 0 · 0 | 0 | 2/24(→←↑↓ ✓) | 4 · 1 | 탭 막대 `_UI_TabBar.ts` · 램프 · `B09_Draft_Print_Page.css` | 중 |
|
||||
| D01 | 0/0 | 0 · 0 | 0 · 1 | 0 | 0 | 공용만 | 소 |
|
||||
| D02 | 1/51 | 0(2·0) · 6 | 0 | 1/0(📍) | 0 | `D02_PlanBase_UI_Page.ts:41` 연도 색(지도) | 소 |
|
||||
| D03 | 1/129 | 1 · 18 | 4 | 0 | 0 · 1 | `D03_PlanAnalysis_UI_Edit.ts:93-103` 캔버스 | 소 |
|
||||
| D04 | 1/62 | 0 | 0 | 0 | 1 | — | 소 |
|
||||
| E01 | 1/21 | 0 | 1 | 0 | 0 | — | 소 |
|
||||
| E02 | 1/186 | 0 | 0 | 0 | 2 | — | 소 |
|
||||
| E03 | 4/891 | 4(0·66) · 3 | 6 | 2/2(★☆ ▦) | 2 | `E03_StabSection_UI_Style.css:8-74` 재료 · 위험 색 정의 | 중 |
|
||||
| E04 | 1/286 | 0 · 18 + 0x 8 | 9 · 캔버스 3 | 0 | 3 | `logic/E04_Stab3D_Raster.ts:74-` 색 띠 | 소~중 |
|
||||
| E05 | 1/81 | 2 · 0 | 0 | 0 | 0 | 화면 = 스프레드시트 | 소 |
|
||||
| E06 | 1/491 | 7(0·7) · 1 | 9 | 0 | 4 | `E06_StabRock_UI_Style.css:8-10` 판정 색 | 중 |
|
||||
| E07 | 1/236 | 0 · 19 | 8 · 캔버스 2 | 0 | 3 | `logic/E07_StabArea_Raster.ts` 색 띠 | 소 |
|
||||
| 스프레드시트 `A00_Common/spreadsheet` | 9/1848 | 5(1·1) · 22 | 33 · 4 | 8/35 | 34 · 2 | `spreadsheet.css:9` 13px · `spreadsheet_grid.css:9-16` 격자 토큰 | 대 |
|
||||
| M02 | 22/4368 | 14(3·12) · 7 | 16 | 13/41(→▸▾ ⚠✓✗🔒) | 37 · 0 | `DataFlow/M02_DF_Diagram.css` 11 · `M02_DF_FuncPanel.css` 9 | 대 |
|
||||
| A군 공개(A01~A09) | 9/747 | 0 · 0 | 0 | 0 | 0 | 토큰만 · 보라 이름 7(A01·A02·A06) | 소(범위 밖 권장) |
|
||||
| 참고 B10 · B11 | 1/14 · 1/72 | 0 | 0 | 0 | 0 | — | 소 |
|
||||
|
||||
- 보라 옛 이름 직접 씀(UI 줄/그림 줄) — ui_template 49/2 · B03 23/0 · B05 23/1 · B06 20/13 · M02 13/0 · A00 11/0 · B04 3 · B07 3 · CAD `--cad-hover: var(--color-mist-violet)`(`App.css:30`).
|
||||
- B05·B06 은 한 페이지 — B05 CSS 가 `b06-chart__*` 를 씀(`B05_Profile_UI_Style.css:155`) → 한 창에서만.
|
||||
|
||||
## ③ 그림 · 출력 색 — 테마를 따르면 안 되는 것
|
||||
|
||||
| 무엇 | 색 자리 | 테마 영향 길 | 지킬 것 |
|
||||
|---|---|---|---|
|
||||
| B06 횡단 · 종단 선 | `B06_Section_UI_Style_Cross_Chart.css:84`(지반 chart-0) · `:96`(chart-1) · `:103` 설계선 royal-amethyst · `:155` EP 측점선 accent · `_Cross_Areas.css:34,47,53`(절·성토 chart-2/3/0) · `:60,108,120,137,144,151` royal-amethyst · `_Style.css:220` | SVG = CSS 변수 상속 → 감싸개 덮어쓰기 · :root 교체 둘 다 번짐 | 그림 전용 토큰(현 값 고정)으로 옮김 |
|
||||
| B05 종단 · 노선 추천 | `B05_Profile_UI_Style_Table.css:363,369` freemark = primary · `_RouteRecommend.css:512` = primary · `_Style.css:156` · 발자국 `_RouteEdit_Footprint.css:3-18` | 같음 | 같음 |
|
||||
| B08 이정표 그림 | `B08_Quantity_UI_Milestone.css:101,108,128` = accent | 같음 | 정할 거리 ② |
|
||||
| E03 · E06 · M02 그림 | `E03_..._UI_Style.css:8-74` 자체 토큰 · `E06_..._UI_Style.css:8-10` · M02 `DataFlow/M02_DF_Diagram.css` | 자체 토큰이라 안전 · `--color-text-secondary` 선 7(E03) · `--color-text`(E06) 만 따라감 | 글 색만 따라가도 됨 |
|
||||
| 지도 2D(캔버스) | `ui_template_theme.css:229-270` `--map-*` · 읽기 `ui_template_palette.ts:33` · `B05_..._RouteEdit_Render.ts:169-370` | `document.documentElement` 에서 읽음 → 감싸개 덮어쓰기는 안 닿음 · :root 교체만 닿음 | `--map-*` 손대지 않음(시험 391) · `Render.ts:172,370` `--color-surface` 바탕은 #f6f7fa→#fff 정도 |
|
||||
| 3D 바탕 | `B05_Profile_UI_Viewer.ts:122-124` · `B04_PreProcess_UI_Camera.ts:468-471` `--color-surface-raised` · B05 `0x` 33 · E04 8 | :root 만 | 흰색 유지면 무해 |
|
||||
| CAD 도면 | `openwebcad/src/App.css:3` theme import · `:31` `--cad-canvas: var(--color-canvas)` · `screenCanvas.drawController.ts:339,342` 바탕 · 격자 · `theme.ts:97` `paintColor`(밝은 바탕 = 흰 종이 가정) · `App.consts.ts:25-236` | iframe 이 theme.css 를 직접 읽음 → :root 교체가 그대로 번짐 · 감싸개 덮어쓰기는 안 닿음(별도 App.css 손질 필요) | `--cad-canvas` #fff 고정 · `paintColor` 손대지 않음 |
|
||||
| 래스터 · 색 띠 | `E04_Stab3D_Raster.ts:74-`·`E07_StabArea_Raster.ts` · `D02_..._UI_Page.ts:41` · `D03_..._UI_Edit.ts:93-103` · `spreadsheet_grid.ts:47` | TS 상수 · 영향 없음 | 그대로 |
|
||||
| B09 표 PDF | `B09_Draft_Print_Router.py:47-78` `PRINT_CSS`(자체 색 · `.ui-data-table__table` 선택자) ← `renderDataTableHtml` | 클래스 이름만 묶임 | 표 클래스 이름 유지 · 전후 PDF 대조 |
|
||||
| B09 도면 PDF | `B09_Draft_Print_Drawings_Pdf.py:8,44-55` `paint_color` = `theme.ts` 짝(시험 `test_302_2_print_drawings`) | 없음 | `theme.ts` 를 고치면 짝도 |
|
||||
| E05 보고서 PDF | `spreadsheet_print_html.ts:200,232-235` `BASE_CSS`(맑은 고딕 10pt · #000) · 서버 묶음 `build:spreadsheet` · 그림 `E05_StabReport_Figure.py:21-22,86` | 없음 | 이 파일 손대지 않음(손대면 서버 묶음 다시 지음) |
|
||||
| 도움말 | `ui_template_help.ts:56` 가이드 webp 300장 표시 · 인쇄 길 없음 | 그림이 옛 모습으로 남음 | 전 창 끝난 뒤 재촬영 한 번 |
|
||||
|
||||
## ④ 깨질 시험 · 촬영 선택자
|
||||
|
||||
값 단정(theme 값을 바꾸면 깨짐)
|
||||
- `test_228_1_table_level_font.py:98-99` — `--text-body: 16px` · `-sm: 14px` · `caption: 12px` / `test_164_detail_zoom.py:157` — 14px
|
||||
- `test_173_2_picker_tree.py:62` — `--wf-left-panel-width: 320px`
|
||||
- `test_391_routeedit_plan_draw.py:81-82` — `--map-route-locked` 밝음 1 · 어두움 2
|
||||
- `test_346_2_footprint_draw.py:116-118` — B05 발자국 hex(CSS · TS 대체값)
|
||||
- `test_177_1:27` · `test_198_1:118` · `test_247_1:107` — `--color-warning/success: #` 두 번 이상(별칭 `var()` 로 바꾸면 깨짐)
|
||||
- `test_185_3_hover_info.py:183` — 어두움 블록에 canvas · mist / `test_218_1_loupe.py:189-190` — `--loupe-border: var(--color-accent)`
|
||||
- `test_546_b01_shell.py:106` — `--b01-accent: #1f7a4d;` · 셸 CSS 에 `:root` 없음(B01 토큰을 올리면 고침)
|
||||
- 화면 스크립트 `ui_checks/45-6_삭제_단추_색.js:7` — danger = rgb(192,57,43) / rgb(232,105,90) 단정 · `43-5_돌아가기_대비.js` · `275_챗봇_테마_엔터.js` = 대비 재기(검증에 다시 씀) · `ui_checks` 96개 중 계산 스타일 29
|
||||
|
||||
규칙 단정(선택자 안 `var(...)` 를 바꾸면 깨짐 — 그림 토큰 옮길 때 같이 고침)
|
||||
- B05 `test_215_1:185-187`(freemark success · danger) · `test_177_1:20-22` · `test_345_5c` · `test_507_3:72-73,86`(warning · 캔버스 `bold 13px`)
|
||||
- B06 `test_318_cross_card.py:162,193,195`(chart-1 · `--b06-tag`) · `test_164:140,146` · `test_173_areas_detail.py:48-50` · `test_207_1`
|
||||
- B08 · B09 `test_198_1:115` · `test_247_1:105` · `test_257_1:55-56`(램프) · `test_235_1:189-192` · `test_200_2:183`(`font-size: 13px` 없음) · `test_203_1` · `test_183_3` · `test_277_1`
|
||||
- 공용 표 · 모달 `test_199_1:75` · `test_243_1` · `test_185_1:74` · `test_modal_top_and_add_word.py:30-31` / B07 `test_212_3:46,55` · `test_280_5` · CAD `test_cad_host_tabs.py`(App.css · App_Layers.css · host-burger.css)
|
||||
- CSS 파일을 읽는 시험 106개 — 많은 순 ui_template 33 · B05 RouteEdit 8 · B06 7 · B08 6 · B06 Cross_Areas 6 · B09 Page 5 · M02 4
|
||||
|
||||
기호 글자 단정(아이콘으로 바꾸면 깨짐)
|
||||
- → 24파일 · ← 6(B09 `test_182` · `184_3` · `200_2:142`「→ 들여쓰기」 · `204_1` · `166_2` · B08 `244_13`) · ▾ 4(`235_1:99` · `184_3` · `246_2` · `345_5c`) · ✓ 5 · ✗ 2(B09 `183_3` · M02 `128_icon_chip_center` · `area_old_links` · `hopyo_flow` · `hopyo_screen`) · ▲▼◀▶(`test_108_drag_box_hit` · `139_6_line_shift` · `271_straighten_pick`)
|
||||
- 클래스 이름을 문자열로 잡는 시험 · 도우미 270 파일 → 이름 바꾸기 금지
|
||||
|
||||
가이드 촬영(`A05_EduDetail/guide/*/_scenes.json` · 300장면)
|
||||
- 표시 선택자 704 — 페이지 클래스 472 · `.ui-*` 183(`ui-collapsible__title` 78 · `ui-sidebar-actions` 63 · `ui-btn` 25 · `ui-btn--filled` 13) · `data-`·id 173 · `has-text` 9 / 대기 · 누름 포함 전체 1777(has-text 52 = B09 28 · E05 11 `ss-tabs__tab:has-text` · B01 10)
|
||||
- 깨지는 꼴 — 클래스 이름 바꿈 · 감싸는 칸 추가(`>` 자식 결합 · `nth-child(7)` · `>> nth=0` 347곳) · 단추 글 바꿈(has-text) · 숨김 기본값 바뀜
|
||||
- 형식 검사 `test_539_guide_capture.py` 는 DOM 을 안 봄 → 실제 확인은 촬영 돌리기뿐(실패 = 종료 코드 1)
|
||||
|
||||
## ⑤ 권하는 순서 · 창 나눔 · 검증 목록
|
||||
|
||||
| 단계 · 창 | 맡는 폴더(겹침 없음) | 크기 |
|
||||
|---|---|---|
|
||||
| 0 공용(한 창 · 먼저 · 합친 뒤 모두 받기) | `ui_template/*`(cad_host 뺌) · `A00_Common/*.css` · `app_shell.ts` · 그림 전용 토큰 추가(현 값 고정 — `--draw-*` 류) · 보라 옛 이름 UI 줄 → 의미 토큰 | 대 |
|
||||
| 1-① B05·B06 | `B05_Profile/` · `B06_Section/` · `A00_Common/design/` — 그림 선택자 55+20줄을 그림 토큰으로 | 대 |
|
||||
| 1-② B07 + CAD | `B07_DesignDetail/`(openwebcad 포함) · `ui_template/cad_host/` — `App.css` 감싸기 · `--cad-canvas` 고정 · `npm run build:b07-cad` | 대 |
|
||||
| 1-③ B08·B09 | `B08_Quantity/` · `B09_Estimation/`(draft 포함) — 탭 막대 · 램프 · 이정표 그림 | 중 |
|
||||
| 1-④ E군 + 스프레드시트 | `E01~E07/` · `A00_Common/spreadsheet/`(`spreadsheet_print_html.ts` 손대지 않음) | 중~대 |
|
||||
| 1-⑤ B02~B04 · D군 | `B02~B04/` · `D01~D04/` | 소~중 |
|
||||
| 2 나중(따로 결정) | `M02_MasterTemplete/` · `M01_MasterData/`(스프레드시트 · cad_host 는 안 만짐) · A군 | 대 |
|
||||
|
||||
창마다 검증 목록
|
||||
1. `npm run typecheck` · 고친 CSS · TS `prettier`
|
||||
2. 대상 시험만 — ④ 의 그 창 몫 + 그 폴더 이름이 든 시험(`pytest resources/tester/<파일> -q`) · 0단계는 `test_228_1 · 164 · 173_2 · 185_3 · 177_1 · 198_1 · 247_1 · 218_1 · 199_1 · 243_1 · 546 · modal_top`
|
||||
3. 이름 불변 대조 — `git diff` 에서 지운 클래스 이름 · 기호 글자를 `resources/tester` · `guide/*/_scenes.json` 에서 grep → 0 이어야 함
|
||||
4. 전후 촬영 — 시작 전 `guide/<P>/_scenes.json` 을 `tmp/brain_375/cap/<P>_before/` 로 복사(촬영기는 out 폴더의 `_scenes.json` 을 읽음) → `./venv/Scripts/python.exe A05_EduDetail/A05_EduDetail_Guide_Capture.py --page <P> --out tmp/brain_375/cap/<P>_before --vite http://localhost:<자기 vite> --project-id <자기 검증 프로젝트>` · 고친 뒤 `_after` 로 같게 · 실패 0 · 3D · 지도 장면은 `--headed`
|
||||
5. 그림 불변 — ORCA `eval` 로 핵심 선 계산 색 전후 같음(B06 `.b06-chart__design-profile` · `__profile` · `__cross-profile` stroke · B05 freemark · B08 이정표 · CAD 바탕 #ffffff · 3D 배경)
|
||||
6. 출력 불변(③ 해당 창) — B09 표 PDF · 도면 PDF · E05 PDF 를 전후로 받아 쪽 수 · 글 · 해시 대조
|
||||
7. 어두움 한 장면(`data-theme=dark`) · 대비 스크립트(`ui_checks/43-5` · `275` 방식)로 강조 단추 · 흐린 글 비율
|
||||
8. CAD 창만 — 빌드 뒤 `orca reload` · 옛 화면이 남으면 새 탭
|
||||
|
||||
## ⑥ 정할 거리
|
||||
|
||||
대비(현행 대 새 값 · 흰 바탕 기준 · WCAG 글 4.5)
|
||||
|
||||
| 짝 | 현행 | 새(B01) |
|
||||
|---|---|---|
|
||||
| 주 단추 흰 글 / 강조 바탕 | 16.65(#26114a) | 5.32(#1f7a4d) 통과 |
|
||||
| 강조 글 / 연한 강조 바탕 #eaf5ee | — | 4.76 통과 |
|
||||
| 흐린 글 / 흰 · 회색 바탕 | 3.08 · 2.87(#9491a1) 미달 | 4.83 · 4.51(#6b7280) 통과 · hover #f3f4f6 위 4.39 살짝 미달 |
|
||||
| 위험 글 / 흰 바탕 | 5.44(#c0392b) | 3.91(#e5484d) 미달 |
|
||||
| 경고 글 #b8860b · 공정 경고 #d97706 | 3.25 · 3.19 미달 | 그대로면 그대로 미달 |
|
||||
| 성공색 #1f8a4c 대 강조 #1f7a4d | — | 1.21 = 사실상 같은 색 |
|
||||
| 원지반선 chart-1 #34c38f 대 강조 | — | 2.37 = 둘 다 초록 |
|
||||
| 어두움 강조 #3fae75 위 #0e1512 · 흐린 글 | — | 6.63 · 6.41 통과 |
|
||||
|
||||
정할 것
|
||||
1. 바꾸는 자리 — :root 값 교체(375-A 안 · A군 공개 화면 · CAD iframe · 캔버스 · 3D 까지 번짐) 대 프로젝트 화면 감싸개 덮어쓰기(B01 식 · 캔버스 · CAD 는 안 닿고 SVG 만 닿음) 중 하나 · A군 공개 사이트를 보라로 둘지.
|
||||
2. 그림 속 강조 — B06 설계선 · EP 선 · B05 freemark · 노선 추천 계획선 · B08 이정표를 지금 보라로 고정할지, 새 강조색을 따를지(따르면 원지반선 · 성공 램프와 초록 셋이 겹침).
|
||||
3. 성공색 — 강조색과 1.21:1 → 「연결됨 · 성공」 램프(B08 · B09)가 주 단추와 구분 안 됨 · 성공색을 옮길지.
|
||||
4. 위험색 — 글자에 쓸 값(#c0392b 유지 권장 · #e5484d 는 채운 바탕용) · `ui_checks/45-6` 단정 고침 여부.
|
||||
5. 글자 크기 — B01 은 11~13px 날값 · 페이지는 16/14/12 토큰 · 내리면 표 · 측점 카드 줄바꿈 · 가이드 표시 위치가 다 바뀜.
|
||||
6. 머리줄 — 프로젝트 화면의 ☰ 서랍(`app-header`) 유지 대 B01 셸(위 머리 + 왼쪽 메뉴) 씌움.
|
||||
7. 기호 → Tabler 아이콘 범위 — 글자 기호(→ ← ▾ ▲▼◀▶ ✓ ✗)는 시험 · has-text 가 글자로 잡음 → 남김 권장 · 그림 이모지(⚠ ⚙ 💾 🖼 🔒 📍 등)만 교체.
|
||||
8. M02 · M01 을 이번에 넣을지 · 가이드 300장 재촬영 시점(모든 창 합친 뒤 한 번).
|
||||
@@ -248,3 +248,23 @@ on 이 맞음. 그런데 shell command가 아님?
|
||||
## 요구 374 (10-26 · 사용자 · 대시보드 반영 지시)
|
||||
|
||||
일단 반영해보자. 봐야 판단이 될것같아.
|
||||
|
||||
## 요구 375 (10-26 · 사용자 · 대시보드 테마를 프로젝트 안까지)
|
||||
|
||||
현재 반영해준 테마가 상당히 괜찮은데 테마, 색상, 폰트, 요소들을 프로젝트 내부 인터페이스에도 반영을 하고 싶어. 전역으로 어떻게 반영할지 신규계획서와 요구서를 작성하는 조사를 진행해줘.
|
||||
|
||||
## 요구 376 (10-26 · 사용자 · 375 답 · 물음)
|
||||
|
||||
테마 변경의 대상은 전체 영역이어야해. 일체감이 있어야하거든 대신 넓은 영역이다보니 현재 개발과정에서 여러 종류를 사용했던것같아. 그래서 템플릿 요소들을 여러개 먼저 만들어두고 재활용하는 방향이 맞아보이는데 어때?
|
||||
|
||||
## 요구 377 (10-26 · 사용자 · 376 답)
|
||||
|
||||
1. 버튼의 사이즈 템플릿은 그래도 최소 사이즈는 5개 이상이 되어야 현재 레이아웃에 영향을 덜 주게 될거야.
|
||||
2. 현재 대시보드에 적용한 글꼴이 pretendard야?
|
||||
3. 프로젝트 내부에는 상단 머리줄을 사용하지 않을생각이야. 동일하게 햄버거 메뉴로 해줘. 웹앱 특성상 세로 공간이 부족해.
|
||||
|
||||
## 요구 378 (10-26 · 사용자 · 377 답)
|
||||
|
||||
1. 버튼은 더 작은 사이즈가 필요해보이는데?
|
||||
2. Pretendard 사용에 라이센스가 없다면 사용하는건 좋은데 프로그램 일체감을 위해 현재 대시보드 폰트도 괜찮아보여.
|
||||
3. 계획서 업데이트 하고 진행해줘.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 요구서 375 · 376 — 전체 영역 테마 통일 · 템플릿 부품 상자
|
||||
|
||||
원문 = `사용자요구_1025.md` 요구 375 · 376(대상 = 전체 영역 · 템플릿 요소를 먼저 만들고 재활용) · 근거 = `_분석_375-A_토큰_공용부품.md`(토큰 · 공용 부품 · 단계) · `_분석_375-B_페이지별_위험.md`(페이지별 차이 · 그림 색 · 시험 · 대비) · 본보기 = 새 대시보드(PLAN 546~549) · 계획 = `PLAN.md` 551~554.
|
||||
|
||||
## 1. 지금
|
||||
|
||||
- 색 값이 두 벌 — 옛 공통 값(`ui_template/ui_template_theme.css` · 보라 · 마케팅 사이트 꼴 · CAD 화면도 씀) / 대시보드 값(`--b01-*` · 초록 · 대시보드 안에서만).
|
||||
- 대부분 페이지는 이미 공통 색 변수만 씀 — 색을 박아 둔 곳은 B06 33 · B05 26 · M02 14 · B03 8 · E06 7 곳 정도.
|
||||
- 가장 큰 위험 = 그림(종단 · 횡단 · 이정표 SVG · CAD 바탕 · 3D 바탕)이 화면 색 변수를 같이 씀 — 바탕 색만 바꾸면 설계선이 초록 원지반선과 겹침.
|
||||
- 인쇄 · PDF(B09 표 · 도면 · E05 보고서)는 화면 CSS 를 안 읽어 색이 새지 않음.
|
||||
- 시험 · 가이드 촬영 장면(704 곳)이 클래스 이름 · 기호 글자를 잡음.
|
||||
|
||||
## 2. 만들 것
|
||||
|
||||
- **템플릿 부품 상자(요구 376)** — 개발 중 화면마다 다른 꼴로 만든 단추 · 탭 · 표 · 배지 · 도구줄 등을 부품 한 벌로 먼저 만들고 모든 화면이 그것을 재활용 · 「부품 모음 화면」(`#/ui-kit`)에 모든 부품을 밝음 / 어두움 · 상태별로 늘어놓아 기준으로 삼음 · 대시보드 PageKit 을 바탕으로 올림 · 변종이 많은 곳 = B05(자체 29 · 날 단추 22) · B06(26 · 12) · M02(37) · 스프레드시트(34) · CAD 앱 · B04(7 · 13) · B08(9).
|
||||
|
||||
- **공통 값 한 벌** — 대시보드 값(초록 강조 #1f7a4d · 바탕 #f6f7f9 · 카드 흰색 · 선 #e6e8ec · 글 #1f2328 · 보조 #6b7280 · 둥글기 10 · 밝음 / 어두움)을 `theme.css` 한 곳에 둠 · 대시보드는 그 값을 받아 씀 · 옛 보라 이름은 새 값의 별칭으로 돌려 260 곳이 저절로 따라옴.
|
||||
- **그림 색은 따로 고정** — 설계선 · 원지반선 · 이정표 · 지도 계획선 · CAD 바탕 · 3D 바탕은 그림 전용 값으로 지금 색 그대로 묶음(화면 테마와 떨어짐).
|
||||
- **공용 부품 모양** — 단추 · 입력 · 카드 · 표 · 탭 · 모달 · 알림 · 오른쪽 메뉴 · 좌측 패널 · 아래 단추 줄을 대시보드 꼴로(클래스 이름 그대로 · 값만) · 공용 탭 모양 하나 새로.
|
||||
- **아이콘** — 그림 이모지(⚠ ⚙ 💾 🔒 📍 등)를 Tabler 아이콘으로 · 글자 기호(→ ▾ ▲▼ ✓)는 그대로(시험이 글자로 잡음).
|
||||
- **글꼴** — 한 토큰(`--font-ui`)으로 묶음 · Noto Sans KR 파일을 프로그램에 실음(SIL OFL).
|
||||
- **배치 · 동작은 그대로** — 왼쪽 패널 · 지도 · 아래 단추 줄 자리 · ☰ 서랍 유지.
|
||||
- **범위 = 전체 영역(요구 376)** — 공개 화면(A) · 로그인 · 임도설계 · 예정노선 · 안정성평가 · 마스터(M01 · M02) · CAD 겉 · 스프레드시트 · 모달 · 알림.
|
||||
|
||||
## 3. 정함(브레인 · 사용자가 바꿀 수 있음)
|
||||
|
||||
| 번호 | 정할 거리 | 정함 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 바꾸는 자리 | `theme.css` 한 곳(전 페이지 · CAD 함께) |
|
||||
| 2 | 그림 속 선 색 | 지금 색 그대로 고정(그림 전용 값) |
|
||||
| 3 | 성공색 · 강조색이 둘 다 초록 | 성공 램프에 ✓ 아이콘 같이(색만으로 안 가름) |
|
||||
| 4 | 위험색 | 글자 = #c0392b(대비 5.4) · 채운 바탕 = #e5484d |
|
||||
| 5 | 글자 크기 | 페이지 16 / 14 / 12 그대로(표 · 측점 줄바꿈 · 촬영 자리 보호) |
|
||||
| 6 | 입력 높이 | 42 그대로 |
|
||||
| 7 | 좌측 패널 | 흰 판 + 1 px 선 · 그림자 없음(대시보드 메뉴 꼴) |
|
||||
| 8 | 가이드 그림 300 장 | 모든 창 합친 뒤 한 번 다시 찍음 |
|
||||
|
||||
## 4. 사용자 확정
|
||||
|
||||
- 요구 376 = 범위 전체 영역(공개 화면 · M01 · M02 포함) · 부품 상자 먼저.
|
||||
- 요구 377 · 378 = ① 단추 크기 여덟 단(18 · 20 · 22 · 24 · 28 · 32 · 36 · 42 · 지금 높이는 가까운 단으로 · 작은 단 = 표 줄 · 도구 아이콘 단추) ② 프로젝트 안 화면 = 위 머리 줄 안 씀 · ☰ 서랍 그대로(세로 공간) ③ 글꼴 = 지금 대시보드 글꼴(Noto Sans KR) 그대로 · 글꼴 파일을 프로그램에 실어 모든 PC 에서 같게(지금은 PC 에 깔린 글꼴에 기댐) · Pretendard 안 씀.
|
||||
|
||||
## 5. 지킬 것
|
||||
|
||||
- 공용 클래스 이름 · data- · 기호 글자는 부품이 그대로 냄 · 페이지 자체 변종을 부품으로 바꿀 때 바뀌는 선택자는 같은 커밋에 시험 · 촬영 장면을 고침.
|
||||
- 단계마다 전후 촬영 대조 · 그림 색 전후 같음 · B09 · E05 PDF 전후 같음.
|
||||
- 새 라이브러리 0.
|
||||
@@ -28,6 +28,8 @@ 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
|
||||
from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router
|
||||
@@ -464,6 +466,8 @@ 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)]
|
||||
# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사).
|
||||
|
||||
@@ -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_Legacy.ts")
|
||||
page = _text("B01_Dashboard/B01_Dashboard_UI_View_SysDefaults.ts")
|
||||
assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page
|
||||
locale = _text("ui_template/ui_template_locale_m1.ts")
|
||||
for group in calc_sites.GROUPS:
|
||||
|
||||
@@ -24,13 +24,16 @@ def _text(path: Path) -> str:
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
text = _text(B01 / "B01_Dashboard_UI_Views.ts")
|
||||
# import 한 값(type 아닌 이름) = 빈 함수 — 페이지 줄이 새 mount 를 끌어와도 그대로 돎
|
||||
stubs = [
|
||||
name.split(" as ")[-1].strip()
|
||||
for group in re.findall(r"^import \{([^}]*)\}", text, flags=re.M)
|
||||
for name in group.split(",")
|
||||
if name.strip() and not name.strip().startswith("type ")
|
||||
]
|
||||
src = re.sub(r"^import[\s\S]*?;\n", "", text, flags=re.M)
|
||||
script = "".join(f"const {n} = () => {{}};\n" for n in stubs) + src + "\n" + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
@@ -99,9 +102,11 @@ def test_셸_한_벌():
|
||||
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
|
||||
# 색 값은 공통 값 한 벌(theme.css 밝음 · 어두움) — 셸은 별칭만 · :root · 어두움 블록 안 둠
|
||||
theme = _text(ROOT / "ui_template" / "ui_template_theme.css")
|
||||
assert "--color-accent: #1f7a4d;" in theme and "--color-accent: #3fae75;" in theme
|
||||
assert "--b01-accent: var(--color-accent);" in css and not re.search(r"^:root", css, re.M)
|
||||
assert not re.search(r"--b01-[a-z-]+:\s*#", css)
|
||||
icons = _text(ROOT / "ui_template" / "ui_template_icons.ts")
|
||||
assert "MIT License" in icons and "Paweł Kuna" in icons
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""547-1 프로젝트 카드 요약 — 썸네일 줄임 · 단계 · 없는 값 빼기 · 보이는 프로젝트만."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import B01_Dashboard.B01_Dashboard_Router_Home as home
|
||||
import B01_Dashboard.B01_Dashboard_Summary as summary
|
||||
|
||||
ROUTE = "B05_Profile/route/r.geojson"
|
||||
|
||||
|
||||
def test_thumb_line_fits_unit_box_and_caps_points():
|
||||
line = [[1000.0 + i, 2000.0 + i * 0.5] for i in range(500)]
|
||||
thumb = summary.thumb_line(line)
|
||||
assert len(thumb) == 100
|
||||
assert thumb[0] == [0.0, 0.75] and thumb[-1] == [1.0, 0.25] # y 아래로 · 세로 가운데
|
||||
assert all(0 <= x <= 1 and 0 <= y <= 1 for x, y in thumb)
|
||||
assert summary.thumb_line([[0.0, 0.0]]) is None
|
||||
|
||||
|
||||
def test_stage_names_follow_step_bar():
|
||||
assert len(summary.stage_names("ROAD")) == 7
|
||||
assert summary.stage_names("PLAN")[0] == "기준 지정"
|
||||
assert summary.stage_names("EROSION") == summary.stage_names("ROAD")
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, db):
|
||||
self.db, self.rows = db, []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=()):
|
||||
self.db["queries"] += 1
|
||||
key = next(k for k in self.db["answers"] if k in sql)
|
||||
self.rows = self.db["answers"][key]
|
||||
|
||||
async def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def acquire(self):
|
||||
pool = self
|
||||
|
||||
class _Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
def cursor(self, *_):
|
||||
return _Cursor(pool.db)
|
||||
|
||||
return _Conn()
|
||||
|
||||
|
||||
def test_summaries_in_batch(monkeypatch, tmp_path):
|
||||
now = datetime.now()
|
||||
route_dir = tmp_path / "B05_Profile" / "route"
|
||||
route_dir.mkdir(parents=True)
|
||||
coords = [[0, 0, 1], [10, 0, 1], [10, 5, 1]]
|
||||
(route_dir / "r.geojson").write_text(json.dumps({"geometry": {"coordinates": coords}}))
|
||||
(route_dir / "structures.json").write_text(json.dumps({"structures": [{}, {}]}))
|
||||
db = {
|
||||
"queries": 0,
|
||||
"answers": {
|
||||
"FROM projects p LEFT JOIN users": [
|
||||
{
|
||||
"id": "a",
|
||||
"name": "가",
|
||||
"project_kind": "ROAD",
|
||||
"region": "강원",
|
||||
"updated_at": now - timedelta(days=20),
|
||||
"storage_path": "storage/a",
|
||||
"company_id": 1,
|
||||
"user_id": 7,
|
||||
"owner_name": "엄",
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"name": "나",
|
||||
"project_kind": "PLAN",
|
||||
"region": None,
|
||||
"updated_at": now,
|
||||
"storage_path": None,
|
||||
"company_id": 1,
|
||||
"user_id": 7,
|
||||
"owner_name": "엄",
|
||||
},
|
||||
],
|
||||
"project_workflow_stages": [
|
||||
{"project_id": "a", "stage_no": n, "stage_key": "", "state": s}
|
||||
| {"progress_percent": 0, "params": None, "message": None}
|
||||
| {"started_at": None, "completed_at": None}
|
||||
for n, s in enumerate(["COMPLETE", "COMPLETE", "IN_PROGRESS"] + ["NOT"] * 4)
|
||||
],
|
||||
"project_members": [{"project_id": "a", "name": "박"}],
|
||||
"FROM routes": [
|
||||
{"id": 9, "project_id": "a", "total_length_m": 15.04, "route_data_path": ROUTE},
|
||||
{"id": 5, "project_id": "a", "total_length_m": 1.0, "route_data_path": "old"},
|
||||
],
|
||||
"cross_sections": [{"route_id": 9, "n": 3}],
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(summary, "get_db_pool", lambda: _Pool(db))
|
||||
monkeypatch.setattr(summary, "project_root", lambda p: tmp_path if p else None)
|
||||
out = asyncio.run(summary.project_summaries(["b", "a", "zz"]))
|
||||
assert [s["id"] for s in out] == ["b", "a"] # 넣은 차례 · 없는 id 빠짐
|
||||
b, a = out
|
||||
assert (a["stage_index"], a["stage_total"], a["stage_name"]) == (2, 7, "노선설계")
|
||||
assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"]
|
||||
assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2}
|
||||
assert len(a["thumb"]) == 3 and a["done"] is False
|
||||
assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "기준 지정"
|
||||
assert db["queries"] == 5
|
||||
|
||||
|
||||
def test_summary_api_drops_invisible_ids(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_visible(session):
|
||||
return ["a", "b"]
|
||||
|
||||
async def fake_summaries(ids):
|
||||
seen["ids"] = ids
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(home, "visible_project_ids", fake_visible)
|
||||
monkeypatch.setattr(home, "project_summaries", fake_summaries)
|
||||
asyncio.run(home.projects_summary(ids="b, x,a,b", session={}))
|
||||
assert seen["ids"] == ["b", "a"]
|
||||
asyncio.run(home.projects_summary(ids=None, session={}))
|
||||
assert seen["ids"] == ["a", "b"]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""547-2 대시보드 홈 — 개수별 꼴 고르기(TS logic) · 역할 가르기 · 역할별 홈 API 칸."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import B01_Dashboard.B01_Dashboard_Router_Home as home
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
LOGIC = ROOT / "B01_Dashboard" / "logic" / "B01_Dashboard_Home_Layout.ts"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as m from "./B01_Dashboard_Home_Layout.js";
|
||||
const counts = [0, 1, 2, 3, 4, 8, 9, 40];
|
||||
const out = {
|
||||
auto: counts.map((n) => m.homeLayout(n)),
|
||||
small: counts.map((n) => m.homeLayout(n, "small")),
|
||||
large: counts.map((n) => m.homeLayout(n, "large")),
|
||||
buckets: m.stageBuckets([
|
||||
{ stage_index: 0, stage_total: 7 }, { stage_index: 3, stage_total: 7 },
|
||||
{ stage_index: 6, stage_total: 7 }, { stage_index: 2, stage_total: 3 },
|
||||
]),
|
||||
stale: [m.isStale({ stage_index: 1, stage_total: 7, stale_days: 13 }),
|
||||
m.isStale({ stage_index: 1, stage_total: 7, stale_days: 14 })],
|
||||
count: m.countBy(["나", "가", "나"], (x) => x),
|
||||
rest: m.topWithRest([5, 4, 3, 2, 1, 1].map((c, i) => ({ label: "c" + i, count: c })), 5),
|
||||
donut: m.donutArcs([1, 1, 2]),
|
||||
};
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts(tmp_path_factory):
|
||||
if not TSC.is_file():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
tmp = tmp_path_factory.mktemp("home")
|
||||
out = tmp / "js"
|
||||
args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"]
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(LOGIC), *args], cwd=str(ROOT), check=True, capture_output=True
|
||||
)
|
||||
for emitted in out.glob("*.js"):
|
||||
text = emitted.read_text(encoding="utf-8")
|
||||
emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8")
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
dst = tmp / "out.json"
|
||||
subprocess.run(["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True) # noqa: S603, S607
|
||||
return json.loads(dst.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_layout_by_count(ts):
|
||||
# 개수 0 1 2 3 4 8 9 40
|
||||
assert ts["auto"] == ["empty", "one", "few", "few", "list", "list", "many", "many"]
|
||||
assert ts["small"] == ["empty", "list", "list", "list", "list", "list", "many", "many"]
|
||||
assert ts["large"] == ["empty", "one", "few", "few", "few", "few", "few", "few"]
|
||||
|
||||
|
||||
def test_counts_and_charts(ts):
|
||||
assert ts["buckets"] == {"start": 1, "working": 1, "last": 2}
|
||||
assert ts["stale"] == [False, True]
|
||||
assert ts["count"] == [{"label": "나", "count": 2}, {"label": "가", "count": 1}]
|
||||
assert ts["rest"][-1] == {"label": "", "count": 2, "rest": 2} and len(ts["rest"]) == 5
|
||||
assert [a["dash"] for a in ts["donut"]] == ["25 75", "25 75", "50 50"]
|
||||
assert [a["offset"] for a in ts["donut"]] == [25, 0, -25]
|
||||
|
||||
|
||||
def test_home_role_order_matches_shell():
|
||||
s = {"email": "a@forest.go.kr"}
|
||||
assert home.home_role({**s, "role": "SYSTEM_ADMIN"}) == "SYSTEM_ADMIN"
|
||||
assert home.home_role({**s, "role": "ADMIN"}) == "ADMIN"
|
||||
assert home.home_role({**s, "role": "USER"}) == "FOREST"
|
||||
assert home.home_role({"email": "a@b.com", "role": "USER"}) == "USER"
|
||||
|
||||
|
||||
class _Cursor:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=()):
|
||||
self.sql = sql
|
||||
|
||||
async def fetchone(self):
|
||||
return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9}
|
||||
|
||||
async def fetchall(self):
|
||||
if "temp_upload_batches" in self.sql or "plan_route_years" in self.sql:
|
||||
return []
|
||||
return [{"id": 1, "name": "회사"}]
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
class _Conn:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
def cursor(self, *_):
|
||||
return _Cursor()
|
||||
|
||||
return _Conn()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("session", "keys", "join"),
|
||||
[
|
||||
({"role": "SYSTEM_ADMIN", "user_id": 1, "company_id": 1}, {"system"}, True),
|
||||
({"role": "ADMIN", "user_id": 1, "company_id": 1}, {"company"}, True),
|
||||
({"role": "USER", "user_id": 1, "company_id": 4, "email": "x@a.go.kr"}, {"forest"}, False),
|
||||
({"role": "USER", "user_id": 1, "company_id": 1, "email": "x@a.com"}, set(), False),
|
||||
],
|
||||
)
|
||||
def test_home_blocks_by_role(monkeypatch, session, keys, join):
|
||||
async def fake_ids(_session, _role):
|
||||
return ["a", "b"]
|
||||
|
||||
async def fake_summaries(ids):
|
||||
return [
|
||||
{"id": i, "done": i == "b", "thumb": [[0, 0], [1, 1]]} for i in ids
|
||||
] # b = 완료 → 빠짐
|
||||
|
||||
async def fake_plans(_session):
|
||||
return {"routes": []}
|
||||
|
||||
async def fake_resources(days):
|
||||
return {"current": {"cpu_usage_percent": 1, "memory_usage_percent": 2}}
|
||||
|
||||
import D01_PlanRegister.D01_PlanRegister_Router as d01
|
||||
|
||||
monkeypatch.setattr(home, "home_project_ids", fake_ids)
|
||||
monkeypatch.setattr(home, "project_summaries", fake_summaries)
|
||||
monkeypatch.setattr(home, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(home, "get_system_resources", fake_resources)
|
||||
monkeypatch.setattr(d01, "assigned_plan_routes", fake_plans)
|
||||
body = asyncio.run(home.dashboard_home(session=session))
|
||||
assert {"system", "company", "forest"} & body.keys() == keys
|
||||
assert [p["id"] for p in body["projects"]] == ["a"] and body["project_total"] == 2
|
||||
assert ("join_requests" in body["todos"]) is join
|
||||
if "system" in body:
|
||||
assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""548 프로젝트 목록 · 상세 — 칸반 나눔 규칙 · 산출물 · 내 것 · 단계 수 짝(서버 · 화면 · 요약)."""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from B01_Dashboard.B01_Dashboard_Router_Overview import (
|
||||
STAGE_TOTAL,
|
||||
board_column,
|
||||
idle_days,
|
||||
is_mine,
|
||||
project_outputs,
|
||||
)
|
||||
from B01_Dashboard.B01_Dashboard_Summary import STAGE_NAMES
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def stages(*states: str) -> list[dict]:
|
||||
return [{"stage_no": i, "state": s} for i, s in enumerate(states)]
|
||||
|
||||
|
||||
N, P, C, S, F = "NOT_STARTED", "IN_PROGRESS", "COMPLETE", "STALE", "FAILED"
|
||||
|
||||
|
||||
def test_board_column_road():
|
||||
assert board_column("ROAD", stages(N, N, N, N, N, N, N)) == "start"
|
||||
assert board_column("ROAD", []) == "start"
|
||||
assert board_column("ROAD", stages(P, N, N, N, N, N, N)) == "design"
|
||||
assert board_column("ROAD", stages(C, C, C, P, N, N, N)) == "design"
|
||||
assert board_column("ROAD", stages(C, C, P, S, N, N, N)) == "review"
|
||||
assert board_column("ROAD", stages(C, F, N, N, N, N, N)) == "review"
|
||||
assert board_column("ROAD", stages(C, C, C, C, C, C, P)) == "confirm"
|
||||
assert board_column("ROAD", stages(C, C, C, C, C, C, N)) == "confirm"
|
||||
assert board_column("ROAD", stages(C, C, C, C, C, C, C)) == "done"
|
||||
# 마지막만 마침이어도 완료(앞이 STALE 이어도)
|
||||
assert board_column("ROAD", stages(C, S, C, C, C, C, C)) == "done"
|
||||
|
||||
|
||||
def test_board_column_other_kinds():
|
||||
assert board_column("PLAN", stages(C, C, P)) == "confirm"
|
||||
assert board_column("PLAN", stages(C, C, C)) == "done"
|
||||
assert board_column("STABILITY", stages(C, P, N, N, N, N)) == "design"
|
||||
assert board_column("STABILITY", stages(N, N, N, N, N)) == "start"
|
||||
# 모르는 종류(사방) = 단계 없음 → 시작 전 · 설계 중
|
||||
assert board_column("EROSION", []) == "start"
|
||||
|
||||
|
||||
def test_outputs_only_when_ready():
|
||||
assert project_outputs("PLAN", stages(C, C, C)) == []
|
||||
assert project_outputs("ROAD", stages(C, C, C, P, N, N, N)) == []
|
||||
keys = lambda st: [o["key"] for o in project_outputs("ROAD", st)] # noqa: E731
|
||||
assert keys(stages(C, C, C, C, P, N, N)) == ["drawings"]
|
||||
assert keys(stages(C, C, C, C, C, C, P)) == ["drawings", "quantity", "cost"]
|
||||
assert keys(stages(C, C, C, C, C, S, S)) == ["drawings", "cost"]
|
||||
|
||||
|
||||
def test_is_mine_and_idle():
|
||||
row = {"user_id": 1, "pm_user_id": 2, "field_lead_user_id": None, "designer_user_id": 3}
|
||||
assert all(is_mine({**row, "member_user_ids": []}, uid) for uid in (1, 2, 3))
|
||||
assert is_mine({**row, "member_user_ids": [9]}, 9)
|
||||
assert not is_mine({**row, "member_user_ids": [9]}, 8)
|
||||
now = datetime(2026, 10, 26, 12)
|
||||
assert idle_days(now - timedelta(days=15, hours=1), now) == 15
|
||||
assert idle_days(None, now) is None
|
||||
|
||||
|
||||
def test_stage_total_pairs():
|
||||
"""단계 수 = 화면 단계 막대(group_workflow · workflowSteps) = 카드 요약(547-1)."""
|
||||
group = (ROOT / "A00_Common/group_workflow.ts").read_text(encoding="utf-8")
|
||||
labels = re.findall(r"labels:\s*\[(.*?)\]", group, re.S)
|
||||
plan, stab = (len(re.findall(r'"DE_Step_\w+"', block)) for block in labels)
|
||||
scaffold = (ROOT / "A00_Common/b_page_scaffold.ts").read_text(encoding="utf-8")
|
||||
road = re.search(r"function workflowSteps\(\)[^{]*\{\s*return \[(.*?)\]", scaffold, re.S).group(1)
|
||||
assert STAGE_TOTAL == {"ROAD": road.count("L("), "PLAN": plan, "STABILITY": stab}
|
||||
assert {k: len(v) for k, v in STAGE_NAMES.items()} == STAGE_TOTAL
|
||||
@@ -0,0 +1,114 @@
|
||||
"""549-1 대시보드 세부 페이지(받은 예정노선 · 임시 보관함 · 사람 · 회사 정보 · 회사 설계 기본값) 시험.
|
||||
|
||||
· 화면 계산(`logic/B01_Dashboard_Logic_Pages.ts`) — 역할 · 상태 한글 · 남은 날 · 막대 · 가입 요청 수 — Node 로 바로 돌림
|
||||
· 등록부 — 네 몫 다섯 열쇠가 옛 구역(legacy)이 아닌 새 페이지를 가리킴 · 역할 값 그대로
|
||||
· 화면 조작 · 눌러 보기는 ORCA
|
||||
"""
|
||||
|
||||
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"
|
||||
LOGIC = B01 / "logic" / "B01_Dashboard_Logic_Pages.ts"
|
||||
MINE = {
|
||||
"assigned": "mountAssigned",
|
||||
"temp": "mountTemp",
|
||||
"people": "mountPeople",
|
||||
"company": "mountCompany",
|
||||
"company-defaults": "mountCompanyDefaults",
|
||||
}
|
||||
|
||||
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _run_logic(body: str) -> object:
|
||||
script = LOGIC.read_text(encoding="utf-8") + "\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)
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_역할_상태_한글_배지():
|
||||
out = _run_logic(
|
||||
"console.log(JSON.stringify(["
|
||||
"roleText('SYSTEM_ADMIN'), roleText('ADMIN'), roleText('USER'), roleText(null),"
|
||||
"statusBadgeOf('ACTIVE'), statusBadgeOf('inactive'), statusBadgeOf('PENDING'),"
|
||||
"statusBadgeOf('REJECTED'), statusBadgeOf('뭔가'), statusBadgeOf(null)]));"
|
||||
)
|
||||
assert out[:4] == ["시스템 관리자", "회사 관리자", "일반 사용자", "-"]
|
||||
assert out[4] == {"text": "활성", "tone": "success"}
|
||||
assert out[5] == {"text": "비활성", "tone": "neutral"}
|
||||
assert out[6] == {"text": "대기", "tone": "warning"}
|
||||
assert out[7] == {"text": "거절", "tone": "danger"}
|
||||
assert out[8] == {"text": "뭔가", "tone": "neutral"}
|
||||
assert out[9] == {"text": "-", "tone": "neutral"}
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_보관함_남은_날_막대_강조():
|
||||
out = _run_logic(
|
||||
"const now = Date.parse('2026-10-26T00:00:00Z');"
|
||||
"const d = (s: string | null) => daysLeft(s, now);"
|
||||
"console.log(JSON.stringify(["
|
||||
"d('2026-11-25T00:00:00Z'), d('2026-11-02T00:00:00Z'), d('2026-11-01T12:00:00Z'),"
|
||||
"d('2026-10-20T00:00:00Z'), d(null), d('아무말'),"
|
||||
"leftPercent(30, 30), leftPercent(15, 30), leftPercent(0, 30), leftPercent(null, 30),"
|
||||
"leftPercent(40, 30), leftPercent(5, 0),"
|
||||
"isExpiringSoon(7), isExpiringSoon(8), isExpiringSoon(0), isExpiringSoon(null)]));"
|
||||
)
|
||||
# 30 일 · 7 일 · 올림(6.5 → 7) · 지남 0 · 없음 · 잘못된 값
|
||||
assert out[:6] == [30, 7, 7, 0, None, None]
|
||||
assert out[6:12] == [100, 50, 0, 0, 100, 0]
|
||||
assert out[12:] == [True, False, True, False]
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_가입_요청_수는_대기만():
|
||||
out = _run_logic(
|
||||
"console.log(JSON.stringify(["
|
||||
"pendingCount([]), pendingCount([{status:'PENDING'},{status:'pending'},{}]),"
|
||||
"pendingCount([{status:'APPROVED'},{status:'REJECTED'}])]));"
|
||||
)
|
||||
assert out == [0, 3, 0]
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_바이트_글():
|
||||
out = _run_logic(
|
||||
"console.log(JSON.stringify([bytesText(100), bytesText(2048), bytesText(5*1024*1024),"
|
||||
"bytesText(3*1024*1024*1024)]));"
|
||||
)
|
||||
assert out == ["1 KB", "2 KB", "5.0 MB", "3.00 GB"]
|
||||
|
||||
|
||||
def test_등록부_네_몫_다섯_열쇠는_새_페이지():
|
||||
text = (B01 / "B01_Dashboard_UI_Views.ts").read_text(encoding="utf-8")
|
||||
for key, mount in MINE.items():
|
||||
line = next(ln for ln in text.splitlines() if f'key: "{key}"' in ln)
|
||||
assert f"mount: {mount}" in line, key
|
||||
assert "legacy(" not in line, key
|
||||
assert f"import {{ {mount} }}" in text, key
|
||||
# 역할 값은 546 그대로
|
||||
assert re.search(r'key: "assigned".*roles: \["USER", "ADMIN", "SYSTEM_ADMIN"\]', text)
|
||||
assert re.search(r'key: "people".*roles: \["ADMIN", "SYSTEM_ADMIN"\]', text)
|
||||
assert re.search(r'key: "company-defaults".*roles: \["USER", "ADMIN", "FOREST"\]', text)
|
||||
|
||||
|
||||
def test_새_페이지_파일은_700줄_이하():
|
||||
files = [*B01.glob("B01_Dashboard_UI_View_*.ts"), B01 / "B01_Dashboard_UI_PageKit.ts",
|
||||
B01 / "B01_Dashboard_UI_TempUpload.ts", LOGIC] # fmt: skip
|
||||
for path in files:
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name
|
||||
@@ -0,0 +1,133 @@
|
||||
"""549-2 시스템 관리 · 내 정보 세부 페이지 시험 — 화면 조작은 ORCA.
|
||||
|
||||
· 거르기 · 게이지 색 · 로그 글 = 순수 함수를 Node 로 바로 돌림
|
||||
· 여섯 열쇠가 새 페이지로 갈아 끼워짐 · 시스템 관리 페이지는 시스템 관리자만(등록부 + 서버 둘 다)
|
||||
"""
|
||||
|
||||
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 _slice(name: str, start: str, end: str) -> str:
|
||||
src = _text(B01 / name)
|
||||
return src[src.index(start) : src.index(end)]
|
||||
|
||||
|
||||
def _node(script: str) -> object:
|
||||
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)
|
||||
|
||||
|
||||
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_게이지_색_선():
|
||||
src = _slice(
|
||||
"B01_Dashboard_UI_View_SysOverview.ts", "export const GAUGE_WARN", "function statCard"
|
||||
)
|
||||
out = _node(
|
||||
src + "console.log(JSON.stringify([null, 0, 69.9, 70, 89.9, 90, 100].map(gaugeTone)));"
|
||||
)
|
||||
assert out == ["ok", "ok", "ok", "warn", "warn", "bad", "bad"]
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_사용자_거르기():
|
||||
src = _slice("B01_Dashboard_UI_View_SysUsers.ts", "/** 역할 · 찾기", "const COLUMNS")
|
||||
users = [
|
||||
{"id": 1, "name": "홍길동", "email": "hong@a.kr", "company_name": "예시건설", "role": "ADMIN"},
|
||||
{"id": 2, "name": "김영희", "email": "kim@b.kr", "company_name": None, "role": "USER"},
|
||||
{"id": 3, "name": "관리", "email": "sys@c.kr", "department": "운영", "role": "SYSTEM_ADMIN"},
|
||||
] # fmt: skip
|
||||
out = _node(
|
||||
"type DashboardUser = any;\n"
|
||||
+ src
|
||||
+ f"const u = {json.dumps(users, ensure_ascii=False)};"
|
||||
+ "console.log(JSON.stringify(["
|
||||
"filterUsers(u, '', '').map(x => x.id),"
|
||||
"filterUsers(u, 'USER', '').map(x => x.id),"
|
||||
"filterUsers(u, '', '예시').map(x => x.id),"
|
||||
"filterUsers(u, '', 'KIM@B').map(x => x.id),"
|
||||
"filterUsers(u, '', 'null').map(x => x.id),"
|
||||
"filterUsers(u, 'ADMIN', '운영').map(x => x.id)]));"
|
||||
)
|
||||
assert out == [[1, 2, 3], [2], [1], [2], [], []]
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_로그_거르기_글():
|
||||
src = _slice("B01_Dashboard_UI_View_SysLogs.ts", "export const ACTION_KO", "function dateInput")
|
||||
logs = [
|
||||
{"action": "PROJECT_CREATE", "email": "a@x.kr", "resource_type": "project",
|
||||
"resource_ref": "12345678-aaaa-bbbb-cccc-1234567890ab", "ip_address": "1.2.3.4",
|
||||
"timestamp": "2026-10-20T09:00:00"},
|
||||
{"action": "COMPANY_CREATE", "email": "b@x.kr", "resource_type": "COMPANY",
|
||||
"resource_id": 7, "ip_address": None, "timestamp": "2026-10-25T10:00:00"},
|
||||
{"action": "NEW_THING", "email": None, "timestamp": "2026-10-26T11:00:00"},
|
||||
] # fmt: skip
|
||||
out = _node(
|
||||
"type AuditLog = any;\n"
|
||||
+ src
|
||||
+ f"const l = {json.dumps(logs, ensure_ascii=False)};"
|
||||
+ "const f = (o: object) => filterLogs(l, { action: '', from: '', to: '', q: '', ...o }).length;"
|
||||
+ "console.log(JSON.stringify(["
|
||||
"actionLabel('PROJECT_CREATE'), actionLabel('NEW_THING'),"
|
||||
"targetText(l[0]), targetText(l[1]), targetText(l[2]),"
|
||||
"f({}), f({ action: 'COMPANY_CREATE' }),"
|
||||
"f({ from: '2026-10-25' }), f({ to: '2026-10-20' }), f({ from: '2026-10-25', to: '2026-10-25' }),"
|
||||
"f({ q: '프로젝트 생성' }), f({ q: '1.2.3' })]));"
|
||||
)
|
||||
assert out == [
|
||||
"프로젝트 생성", "NEW_THING", "프로젝트 12345678…", "회사 7", "-",
|
||||
3, 1, 2, 1, 1, 1, 1,
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_여섯_열쇠_새_페이지():
|
||||
views = _text(B01 / "B01_Dashboard_UI_Views.ts")
|
||||
want = {
|
||||
"sys-overview": "mountSysOverview", "sys-companies": "mountSysCompanies",
|
||||
"sys-users": "mountSysUsers", "sys-defaults": "mountSysDefaults",
|
||||
"sys-logs": "mountSysLogs", "me": "mountMe",
|
||||
} # fmt: skip
|
||||
for key, mount in want.items():
|
||||
line = next(x for x in views.splitlines() if f'key: "{key}"' in x)
|
||||
assert f"mount: {mount} " in line + " ", key
|
||||
if key != "me":
|
||||
assert 'roles: ["SYSTEM_ADMIN"]' in line, key # 시스템 관리 = 시스템 관리자만
|
||||
legacy = _text(B01 / "B01_Dashboard_UI_Legacy.ts")
|
||||
for old in ("sysOverview", "sysCompanies", "sysUsers", "sysDefaults", "sysLogs", " me: (s)"):
|
||||
assert old not in legacy, old # 옛 구역 지움
|
||||
|
||||
|
||||
def test_서버도_시스템_관리자만():
|
||||
router = _text(B01 / "B01_Dashboard_Router.py")
|
||||
for route in ("/admin/companies", "/admin/users", "/admin/audit-logs", "/admin/resources"):
|
||||
block = re.search(r'@router\.get\("%s"\)[\s\S]*?\n\n\n' % re.escape(route), router)
|
||||
assert block and "Depends(require_system_admin)" in block.group(0), route
|
||||
|
||||
|
||||
def test_로그_쪽_불러오기_있음():
|
||||
api = _text(B01 / "B01_Dashboard_Api_Fetch.ts")
|
||||
assert "export async function fetchAuditLogPage(" in api
|
||||
assert "/dashboard/admin/audit-logs?page=${page}&limit=${limit}" in api
|
||||
@@ -0,0 +1,43 @@
|
||||
"""551-1 공통 값 한 벌 · 글꼴 파일 실음 — 지킴 시험."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
UI = ROOT / "ui_template"
|
||||
THEME = (UI / "ui_template_theme.css").read_text(encoding="utf-8")
|
||||
FONTS = UI / "fonts"
|
||||
|
||||
|
||||
def test_글꼴_한_토큰_실은_파일():
|
||||
assert THEME.lstrip().startswith("/*") and '@import url("./fonts/noto_sans_kr.css");' in THEME
|
||||
assert THEME.index("@import") < THEME.index(":root {") # @import 는 규칙보다 앞
|
||||
assert '--font-ui: "Noto Sans KR",' in THEME
|
||||
assert "--font-body: var(--font-ui);" in THEME and "--font-display: var(--font-ui);" in THEME
|
||||
for name in ("Inter", "Britti", "Pretendard", "Plus Jakarta"):
|
||||
assert name not in THEME, name
|
||||
css = (FONTS / "noto_sans_kr.css").read_text(encoding="utf-8")
|
||||
files = re.findall(r'url\("\./(noto-sans-kr-\d+\.woff2)"\)', css)
|
||||
assert files and len(files) == css.count("@font-face")
|
||||
for f in files:
|
||||
assert (FONTS / f).stat().st_size > 1000, f
|
||||
assert css.count('font-family: "Noto Sans KR";') == len(files)
|
||||
assert css.count("unicode-range:") == len(files)
|
||||
assert "SIL OPEN FONT LICENSE Version 1.1" in (FONTS / "OFL.txt").read_text(encoding="utf-8")
|
||||
assert len(css.splitlines()) <= 700
|
||||
|
||||
|
||||
def test_옛_보라_이름은_별칭():
|
||||
root = THEME[THEME.index(":root {") :]
|
||||
root = root[: root.index("\n}\n")]
|
||||
for old, new in (
|
||||
("deep-iris", "text"),
|
||||
("plum-velvet", "text"),
|
||||
("royal-amethyst", "accent"),
|
||||
("mist-violet", "accent-soft"),
|
||||
):
|
||||
assert f"--color-{old}: var(--color-{new});" in root, old
|
||||
assert "--color-accent: #1f7a4d;" in root and "--color-danger: #c0392b;" in root
|
||||
assert "--color-danger-fill: #e5484d;" in root
|
||||
dark = THEME[THEME.index(':root[data-theme="dark"]') :]
|
||||
assert "deep-iris" not in dark and "royal-amethyst" not in dark # 어두움도 별칭으로 따라옴
|
||||
@@ -15,7 +15,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Legacy.ts" # 옛 구역 부품(546)
|
||||
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)
|
||||
|
||||
@@ -31,7 +31,7 @@ def _fn(src: str, name: str) -> str:
|
||||
|
||||
|
||||
def _cards(user: dict, company_id: int | None = None) -> list:
|
||||
src = _text(PAGE)
|
||||
# 카드 = settingLinks 한 벌 · 누름 = 설계 기본값 페이지(SysDefaults)와 같은 갈래(계산 자리만 창)
|
||||
script = (
|
||||
"interface DashboardUser { role: string; is_master: boolean }\n"
|
||||
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data',"
|
||||
@@ -41,21 +41,13 @@ def _cards(user: dict, company_id: int | None = None) -> list:
|
||||
"const navigateTo = (r: string, q?: Record<string, string>) =>"
|
||||
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
|
||||
"const openCalcSitesDialog = async () => { went = 'calc-sites-dialog'; };\n"
|
||||
"const mk = (): any => ({ children: [] as any[], textContent: '', className: '',"
|
||||
" on: null as any,"
|
||||
" append(...c: any[]) { this.children.push(...c); },"
|
||||
" addEventListener(_: string, f: any) { this.on = f; } });\n"
|
||||
"const document = { createElement: () => mk() };\n"
|
||||
+ _fn(src, "settingCard")
|
||||
+ "\n"
|
||||
+ _fn(_text(NAV), "settingLinks")
|
||||
+ "\n"
|
||||
+ _fn(src, "buildSystemSettingsPanel")
|
||||
+ "\nconst user = JSON.parse(process.argv[1]);"
|
||||
+ "\nconst panel = buildSystemSettingsPanel(user, JSON.parse(process.argv[2]));"
|
||||
+ "\nconsole.log(JSON.stringify(panel.children.map((c: any) => {"
|
||||
+ " c.on(); return [c.children[0].textContent, c.children[1].textContent,"
|
||||
+ " decodeURIComponent(went)]; })));"
|
||||
+ "\nconst links = settingLinks(user, JSON.parse(process.argv[2]));"
|
||||
+ "\nconsole.log(JSON.stringify(links.map((l: any) => {"
|
||||
+ " if (l.key === 'calcSites') void openCalcSitesDialog();"
|
||||
+ " else navigateTo(l.route, l.query);"
|
||||
+ " return [l.title, l.desc, decodeURIComponent(went)]; })));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
@@ -115,11 +107,10 @@ def test_회사_고르기_시스템_관리자만():
|
||||
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
|
||||
assert "openAddMemberModal(undefined, s.adminCompanyId)" in src
|
||||
people = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_View_People.ts")
|
||||
for call in ("fetchAllUsers(ctx.companyId)", "fetchCompanyJoinRequests(system ? ctx.companyId",
|
||||
"openAddMemberModal(undefined, companyId)"): # fmt: skip
|
||||
assert call in people, call
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
|
||||
|
||||
Noto Sans KR — subset into unicode-range slices (noto-sans-kr-*.woff2) for web embedding.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) and the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,681 @@
|
||||
/* Noto Sans KR(가변 굵기 100~900 · SIL OFL 1.1 · OFL.txt) — 글자 묶음별로 나눈 파일.
|
||||
* 00 = 화면 글(라틴 · 문장 부호 · 코드에 쓰인 한글) · 나머지 = 한글 · 한자 등 쓰일 때만 받음. */
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-00.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0020-007E, U+00A0-00FF, U+0394, U+03A3, U+03A6, U+03B1-03B3, U+03B8, U+03BB-03BC, U+03C0-03C1,
|
||||
U+03C3-03C4, U+03C6, U+2002-2003, U+2010-2016, U+2018-201A, U+201C-201E, U+2020-2022,
|
||||
U+2025-2027, U+2030, U+2032-2033, U+2035, U+2039-203C, U+2042, U+2047-2049, U+2051, U+20A9,
|
||||
U+2103, U+2113, U+2160, U+2190-2196, U+21C4, U+21CB, U+21D2, U+21E7, U+21E9, U+2205,
|
||||
U+2211-2212, U+221A, U+221D-221E, U+2220, U+2229-222A, U+2248, U+2260-2261, U+2264-2265, U+226B,
|
||||
U+2295, U+22A5, U+22EF, U+2312, U+2318, U+2423, U+2460-246F, U+2471-2472, U+24D1, U+24D8,
|
||||
U+2500, U+2502, U+2504, U+2508, U+2514, U+251C, U+2550, U+2571, U+25A1, U+25A4, U+25A6-25A9,
|
||||
U+25B2-25B3, U+25B6, U+25BC, U+25C0, U+25C7, U+25C9, U+25CB-25CC, U+25CE-25D0, U+2600-2601,
|
||||
U+2605-2606, U+26A0, U+2702, U+2713, U+2B06-2B07, U+3007, U+300C-300D, U+3131, U+3134, U+3137,
|
||||
U+314B, U+314E-314F, U+315B, U+3163, U+318E, U+3251, U+3253-325A, U+3261, U+326F-3270, U+338F,
|
||||
U+3396, U+339C-339E, U+33A1-33A2, U+33A5, U+33CA, U+5148, U+524D, U+534A, U+5F26, U+5F8C,
|
||||
U+6BB5, U+8A3B, U+975E, U+AC00-AC01, U+AC04, U+AC07-AC08, U+AC10-AC12, U+AC14-AC16, U+AC19,
|
||||
U+AC1B-AC1D, U+AC20, U+AC24, U+AC2C-AC2D, U+AC31, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A,
|
||||
U+AC80-AC81, U+AC83, U+AC89, U+AC8C, U+ACA0, U+ACA8-ACAA, U+ACAC, U+ACB0, U+ACB8-ACB9,
|
||||
U+ACBC-ACBD, U+ACC1, U+ACC4, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACF1, U+ACF3, U+ACF5,
|
||||
U+ACFC-ACFD, U+AD00, U+AD04, U+AD11, U+AD1C, U+AD34, U+AD50, U+AD6C-AD6D, U+AD70, U+AD73-AD75,
|
||||
U+AD7C-AD7D, U+AD8C, U+ADA4, U+ADC0, U+ADDC, U+ADE0, U+ADF8-ADF9, U+ADFC, U+AE00-AE01,
|
||||
U+AE08-AE09, U+AE0B, U+AE30, U+AE34, U+AE38, U+AE40-AE41, U+AE43, U+AE4A, U+AE4C-AE4E, U+AE50,
|
||||
U+AE54, U+AE5C-AE5D, U+AE65, U+AE68, U+AE6C, U+AE78, U+AE7C, U+AEBC, U+AEBE, U+AECD, U+AED0,
|
||||
U+AED8, U+AEF4, U+AF2C-AF2D, U+AF34, U+AF42, U+AF49, U+AFB8, U+AFBC, U+AFC0, U+AFC8, U+AFD4,
|
||||
U+AFE8, U+AFF0, U+B00C, U+B010, U+B014, U+B01C-B01D, U+B044, U+B048, U+B04A, U+B04C, U+B04E,
|
||||
U+B054, U+B057, U+B05D, U+B07C, U+B080, U+B08C, U+B098-B099, U+B09C, U+B0A0-B0A1, U+B0A8-B0A9,
|
||||
U+B0AB-B0AE, U+B0B1, U+B0B3-B0B4, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C8, U+B0D0, U+B0E5,
|
||||
U+B108-B109, U+B10C, U+B110, U+B113, U+B118, U+B11B, U+B123-B124, U+B128, U+B134, U+B137-B138,
|
||||
U+B140, U+B144, U+B150, U+B155, U+B178-B179, U+B17C, U+B192-B193, U+B1A8, U+B204-B205, U+B208,
|
||||
U+B20C, U+B214-B215, U+B217, U+B220, U+B258, U+B25C, U+B268, U+B274, U+B289, U+B290, U+B294,
|
||||
U+B298, U+B29A, U+B2A0, U+B2A5-B2A6, U+B2AC, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D8-B2D9, U+B2DB,
|
||||
U+B2DD, U+B2E4-B2E5, U+B2E8, U+B2EB-B2EE, U+B2F4-B2F5, U+B2F9, U+B2FB, U+B2FF-B300, U+B304,
|
||||
U+B310-B311, U+B313, U+B354-B355, U+B358, U+B35C, U+B35F, U+B364, U+B367, U+B369, U+B36E,
|
||||
U+B370-B371, U+B378, U+B38C, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE, U+B3D4-B3D5, U+B3D9,
|
||||
U+B3FC, U+B410, U+B418, U+B41C, U+B420, U+B428-B429, U+B450, U+B454, U+B458, U+B460-B461,
|
||||
U+B465, U+B46C, U+B480, U+B4A4, U+B4B7, U+B4C8, U+B4DC-B4DD, U+B4E0, U+B4E3-B4E4, U+B4E6,
|
||||
U+B4EC-B4ED, U+B4EF, U+B4F1, U+B514-B515, U+B518, U+B529-B52A, U+B530-B531, U+B534, U+B538,
|
||||
U+B545, U+B54C, U+B550, U+B5A0, U+B5A4, U+B5A8, U+B5B4, U+B5BB-B5BC, U+B5C0, U+B5C4, U+B5CC,
|
||||
U+B5D0, U+B610-B611, U+B69C-B69D, U+B6AB, U+B6B1, U+B6F0, U+B6F4, U+B6F8, U+B700, U+B728,
|
||||
U+B72C, U+B730, U+B738-B739, U+B73B, U+B744, U+B748, U+B760, U+B77C-B77D, U+B780, U+B784,
|
||||
U+B78C-B78D, U+B790-B791, U+B797-B799, U+B79C, U+B7A8-B7A9, U+B7AB-B7AC, U+B7B5, U+B7C9,
|
||||
U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B81B, U+B824-B825,
|
||||
U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D,
|
||||
U+B86F, U+B8B0, U+B8CC, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9, U+B904, U+B958, U+B960,
|
||||
U+B974, U+B978, U+B97C, U+B984-B985, U+B987, U+B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD,
|
||||
U+B9BF, U+B9C1, U+B9C8-B9C9, U+B9CC, U+B9CE, U+B9D0-B9D1, U+B9DD-B9DE, U+B9E1, U+B9E4-B9E5,
|
||||
U+B9E8, U+B9F4-B9F5, U+B9F9-B9FA, U+BA38-BA39, U+BA3C, U+BA40, U+BA42, U+BA48, U+BA4B,
|
||||
U+BA4D-BA4E, U+BA54, U+BA58, U+BA64, U+BA70-BA71, U+BA74, U+BA78, U+BA85, U+BA87, U+BAA8-BAA9,
|
||||
U+BAAB-BAAC, U+BAB0, U+BAB8, U+BABB, U+BB34-BB36, U+BB38, U+BB3B-BB3C, U+BB3E, U+BB49, U+BBA4,
|
||||
U+BBC0, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08, U+BC0B, U+BC0F, U+BC11, U+BC14-BC16,
|
||||
U+BC18, U+BC1B-BC1D, U+BC29, U+BC30-BC31, U+BC34, U+BC43, U+BC84, U+BC88, U+BC8C, U+BC94-BC95,
|
||||
U+BC97, U+BC9A, U+BCA0-BCA1, U+BCA4, U+BCA8, U+BCB3, U+BCBC-BCBD, U+BCC0, U+BCC4, U+BCCD,
|
||||
U+BCD1, U+BCF4-BCF5, U+BCF8, U+BCFC, U+BD04-BD05, U+BD07, U+BD09, U+BD10, U+BD24, U+BD80-BD81,
|
||||
U+BD84, U+BD88-BD89, U+BD90, U+BD93, U+BD95, U+BD99, U+BDF0, U+BE0C, U+BE14, U+BE44, U+BE48,
|
||||
U+BE4C, U+BE54, U+BE57, U+BE59, U+BE5B, U+BE60-BE61, U+BE68, U+BE7C, U+BE80, U+BE84, U+BE8C,
|
||||
U+BE8F-BE90, U+BED4, U+BED7, U+BF08, U+BF18, U+BF51, U+BFB0, U+BFCC, U+BFD0, U+BFD4, U+BFDC,
|
||||
U+C058, U+C05C, U+C090, U+C0AC-C0AD, U+C0B0, U+C0B4, U+C0B6, U+C0BC-C0BD, U+C0C1, U+C0C8-C0C9,
|
||||
U+C0D8, U+C0DC-C0DD, U+C0F7, U+C11C-C11E, U+C120, U+C124, U+C12C-C12D, U+C12F-C131, U+C138-C139,
|
||||
U+C13C, U+C140, U+C148-C149, U+C14B-C14C, U+C154, U+C158, U+C168, U+C170, U+C178, U+C18C-C18E,
|
||||
U+C190, U+C194, U+C19F, U+C1A1, U+C1C4, U+C1E0, U+C218, U+C21C, U+C220, U+C228, U+C22B, U+C26C,
|
||||
U+C274, U+C27C-C27D, U+C290, U+C2A4, U+C2A8, U+C2AC-C2AD, U+C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD,
|
||||
U+C2E0, U+C2E3-C2E4, U+C2EC-C2ED, U+C2F1, U+C2F6, U+C2F8, U+C2FC, U+C308, U+C30D, U+C313,
|
||||
U+C368, U+C36C, U+C378, U+C37C, U+C3D8, U+C3DC, U+C3E0, U+C3E8, U+C3F4, U+C410, U+C4F0, U+C4F4,
|
||||
U+C4F8, U+C500-C501, U+C50C, U+C528-C529, U+C52C, U+C544-C545, U+C548-C54A, U+C54C, U+C54E,
|
||||
U+C554-C555, U+C557-C559, U+C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C574-C575,
|
||||
U+C57C-C57D, U+C587, U+C591, U+C595, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BD, U+C5C4-C5C9, U+C5CE,
|
||||
U+C5D0-C5D1, U+C5D4, U+C5D8, U+C5E3, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6, U+C5FC-C5FD,
|
||||
U+C5FF-C601, U+C605-C606, U+C608, U+C61B, U+C624, U+C628, U+C62C, U+C62E, U+C634-C635, U+C639,
|
||||
U+C640, U+C644, U+C651, U+C654-C655, U+C65C, U+C678, U+C67C, U+C694, U+C6A9, U+C6B0, U+C6B4,
|
||||
U+C6B8, U+C6BA, U+C6C0-C6C1, U+C6C3, U+C6CC, U+C6D0, U+C6D4, U+C6E0, U+C6F9, U+C704, U+C708,
|
||||
U+C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728, U+C73C, U+C740, U+C744, U+C74C-C74D,
|
||||
U+C751, U+C758, U+C774-C775, U+C778, U+C77C-C77D, U+C783-C785, U+C787-C78A, U+C78E, U+C790-C791,
|
||||
U+C794, U+C796, U+C798, U+C7A0-C7A1, U+C7A3, U+C7A5-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4,
|
||||
U+C7BC-C7BD, U+C7C0, U+C7C8, U+C800-C801, U+C804, U+C808, U+C810-C811, U+C815-C816, U+C81C-C81D,
|
||||
U+C820, U+C82F, U+C838, U+C84C, U+C870-C871, U+C874, U+C878, U+C880-C881, U+C885, U+C887,
|
||||
U+C88B-C88C, U+C8FC-C8FD, U+C900, U+C904, U+C906, U+C90C-C90D, U+C911, U+C918, U+C92C, U+C950,
|
||||
U+C954, U+C960, U+C988-C989, U+C998, U+C99D, U+C9C0-C9C1, U+C9C4, U+C9C8, U+C9D0-C9D1, U+C9D3,
|
||||
U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E4, U+C9E7, U+C9EC, U+C9F8, U+CA4D, U+CA61, U+CABC-CABD,
|
||||
U+CB48-CB49, U+CBE4, U+CC0C-CC0D, U+CC28-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38, U+CC3D-CC3E,
|
||||
U+CC44-CC45, U+CC48, U+CC4C, U+CC54, U+CC57, U+CC59, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9,
|
||||
U+CCAB, U+CCAD, U+CCB4, U+CCD0, U+CCE4, U+CD08-CD09, U+CD18, U+CD1D, U+CD5C, U+CD94-CD95,
|
||||
U+CD98, U+CD9C, U+CDA4-CDA5, U+CDA9, U+CDB0, U+CDC4, U+CDE8, U+CE20-CE21, U+CE35, U+CE58-CE59,
|
||||
U+CE5C, U+CE60, U+CE68-CE69, U+CE6D, U+CE74, U+CE78, U+CE90, U+CE94, U+CE98, U+CEA1,
|
||||
U+CEE4-CEE5, U+CEE8, U+CEEC, U+CEF4, U+CEF7-CEF9, U+CF00, U+CF08, U+CF13, U+CF1C, U+CF20,
|
||||
U+CF24, U+CF2C, U+CF30, U+CF54-CF55, U+CF58, U+CF5C, U+CF64, U+CFE0, U+CFE8, U+CFFC, U+D000,
|
||||
U+D034, U+D06C, U+D070, U+D074, U+D07C, U+D0A4, U+D0A8, U+D0AC, U+D0B4, U+D0B7, U+D0B9,
|
||||
U+D0C0-D0C1, U+D0C4, U+D0C8, U+D0D0-D0D1, U+D0D3, U+D0D5, U+D0DC-D0DD, U+D0ED, U+D130-D131,
|
||||
U+D134, U+D140, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D, U+D1A0-D1A1, U+D1A4, U+D1A8, U+D1B1,
|
||||
U+D1B5, U+D1F4, U+D22C-D22D, U+D234, U+D241, U+D280, U+D284, U+D290, U+D295, U+D29C,
|
||||
U+D2B8-D2B9, U+D2BC, U+D2C0, U+D2C8, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D305, U+D30C,
|
||||
U+D30E, U+D310, U+D314, U+D31D, U+D328-D329, U+D32C, U+D33D, U+D37C, U+D398-D399, U+D39C,
|
||||
U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC,
|
||||
U+D45C, U+D478, U+D47C, U+D480, U+D482, U+D488, U+D48B, U+D48D, U+D504, U+D508, U+D50C,
|
||||
U+D53C-D53D, U+D540, U+D544, U+D54F, U+D551, U+D558-D559, U+D55C, U+D560, U+D568-D569, U+D56D,
|
||||
U+D574-D575, U+D578, U+D588-D589, U+D5A5, U+D5C8, U+D5CC, U+D5D0, U+D5D8, U+D5DB, U+D5E4,
|
||||
U+D5EC, U+D5F7, U+D600, U+D604, U+D611, U+D614-D615, U+D638-D639, U+D63C, U+D640, U+D648-D649,
|
||||
U+D64D, U+D654-D655, U+D658, U+D65C, U+D669, U+D68C-D68D, U+D69F, U+D6A1, U+D6A8, U+D6C4-D6C5,
|
||||
U+D6C8, U+D6D1, U+D6E8, U+D718, U+D71C, U+D720, U+D734, U+D744, U+D749, U+D750-D751, U+D754,
|
||||
U+D758-D759, U+D760-D761, U+D765, U+D769, U+D770, U+D788, U+D78C, U+D790, U+D798-D799, U+D7A3,
|
||||
U+FF08-FF09, U+FF0B, U+FF0D, U+FF1A, U+FF5E, U+1F170;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-01.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+AC02-AC03, U+AC05-AC06, U+AC09-AC0F, U+AC13, U+AC17-AC18, U+AC1A, U+AC1E-AC1F, U+AC21-AC23,
|
||||
U+AC25-AC2B, U+AC2E-AC30, U+AC32-AC6F, U+AC72-AC73, U+AC75-AC76, U+AC79, U+AC7B-AC7F, U+AC82,
|
||||
U+AC84-AC88, U+AC8A-AC8B, U+AC8D-AC9F, U+ACA1-ACA7, U+ACAB, U+ACAD-ACAF, U+ACB1-ACB7,
|
||||
U+ACBA-ACBB, U+ACBE-ACC0, U+ACC2-ACC3, U+ACC5-ACDF, U+ACE2-ACE3, U+ACE5-ACE6, U+ACE9-ACF0,
|
||||
U+ACF2, U+ACF4, U+ACF6-ACFB, U+ACFE-ACFF, U+AD01-AD03, U+AD05-AD10, U+AD12-AD1B, U+AD1D-AD33,
|
||||
U+AD35-AD4F, U+AD51-AD6B, U+AD6E-AD6F, U+AD71-AD72, U+AD76-AD7B, U+AD7E-AD8B, U+AD8D-ADA3,
|
||||
U+ADA5-ADBF, U+ADC1-ADD5;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-02.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+ADD6-ADDB, U+ADDD-ADDF, U+ADE1-ADF7, U+ADFA-ADFB, U+ADFD-ADFF, U+AE02-AE07, U+AE0A,
|
||||
U+AE0C-AE2F, U+AE31-AE33, U+AE35-AE37, U+AE39-AE3F, U+AE42, U+AE44-AE49, U+AE4B, U+AE4F,
|
||||
U+AE51-AE53, U+AE55-AE5B, U+AE5E-AE64, U+AE66-AE67, U+AE69-AE6B, U+AE6D-AE77, U+AE79-AE7B,
|
||||
U+AE7D-AEBB, U+AEBD, U+AEBF-AECC, U+AECE-AECF, U+AED1-AED7, U+AED9-AEF3, U+AEF5-AF2B,
|
||||
U+AF2E-AF33, U+AF35-AF41, U+AF43-AF48, U+AF4A-AF8D;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-03.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+AF8E-AFB7, U+AFB9-AFBB, U+AFBD-AFBF, U+AFC1-AFC7, U+AFC9-AFD3, U+AFD5-AFE7, U+AFE9-AFEF,
|
||||
U+AFF1-B00B, U+B00D-B00F, U+B011-B013, U+B015-B01B, U+B01E-B043, U+B045-B047, U+B049, U+B04B,
|
||||
U+B04D, U+B04F-B053, U+B055-B056, U+B058-B05C, U+B05E-B07B, U+B07D-B07F, U+B081-B08B,
|
||||
U+B08D-B097, U+B09A-B09B, U+B09D-B09F, U+B0A2-B0A7, U+B0AA, U+B0AF-B0B0, U+B0B2, U+B0B5-B0B7,
|
||||
U+B0B9-B0BB, U+B0BD-B0C3, U+B0C6-B0C7, U+B0C9-B0CF, U+B0D1-B0E4, U+B0E6-B107, U+B10A-B10B,
|
||||
U+B10D-B10F, U+B111-B112, U+B114-B117, U+B119-B11A, U+B11C-B122, U+B125-B127, U+B129-B133,
|
||||
U+B135-B136, U+B139-B13F, U+B141-B143, U+B145-B14F, U+B151-B154, U+B156-B15A;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-04.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+B15B-B177, U+B17A-B17B, U+B17D-B191, U+B194-B1A7, U+B1A9-B203, U+B206-B207, U+B209-B20B,
|
||||
U+B20D-B213, U+B216, U+B218-B21F, U+B221-B257, U+B259-B25B, U+B25D-B267, U+B269-B273,
|
||||
U+B275-B288, U+B28A-B28F, U+B291-B293, U+B295-B297, U+B299, U+B29B-B29F, U+B2A1-B2A4,
|
||||
U+B2A7-B2AB, U+B2AD-B2C7, U+B2CA-B2CB, U+B2CD-B2CF, U+B2D1-B2D7, U+B2DA, U+B2DC, U+B2DE-B2E3,
|
||||
U+B2E6-B2E7, U+B2E9-B2EA, U+B2EF-B2F3, U+B2F6-B2F8, U+B2FA, U+B2FC-B2FE, U+B301-B303,
|
||||
U+B305-B30F, U+B312, U+B314-B31E;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-05.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+B31F-B353, U+B356-B357, U+B359-B35B, U+B35D-B35E, U+B360-B363, U+B365-B366, U+B368,
|
||||
U+B36A-B36D, U+B36F, U+B372-B377, U+B379-B38B, U+B38D-B3C3, U+B3C6-B3C7, U+B3C9-B3CA, U+B3CD,
|
||||
U+B3CF-B3D3, U+B3D6-B3D8, U+B3DA-B3FB, U+B3FD-B40F, U+B411-B417, U+B419-B41B, U+B41D-B41F,
|
||||
U+B421-B427, U+B42A-B44F, U+B451-B453, U+B455-B457, U+B459-B45F, U+B462-B464, U+B466-B46B,
|
||||
U+B46D-B47F, U+B481-B4A3, U+B4A5-B4B6, U+B4B8-B4C7, U+B4C9-B4D6;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-06.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+B4D7-B4DB, U+B4DE-B4DF, U+B4E1-B4E2, U+B4E5, U+B4E7-B4EB, U+B4EE, U+B4F0, U+B4F2-B513,
|
||||
U+B516-B517, U+B519-B528, U+B52B-B52F, U+B532-B533, U+B535-B537, U+B539-B544, U+B546-B54B,
|
||||
U+B54D-B54F, U+B551-B59F, U+B5A1-B5A3, U+B5A5-B5A7, U+B5A9-B5B3, U+B5B5-B5BA, U+B5BD-B5BF,
|
||||
U+B5C1-B5C3, U+B5C5-B5CB, U+B5CD-B5CF, U+B5D1-B60F, U+B612-B688;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-07.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+B689-B69B, U+B69E-B6AA, U+B6AC-B6B0, U+B6B2-B6EF, U+B6F1-B6F3, U+B6F5-B6F7, U+B6F9-B6FF,
|
||||
U+B701-B727, U+B729-B72B, U+B72D-B72F, U+B731-B737, U+B73A, U+B73C-B743, U+B745-B747,
|
||||
U+B749-B75F, U+B761-B77B, U+B77E-B77F, U+B781-B783, U+B785-B78B, U+B78E-B78F, U+B792-B796,
|
||||
U+B79A-B79B, U+B79D-B7A7, U+B7AA, U+B7AD-B7B4, U+B7B6-B7C8, U+B7CA-B7EB, U+B7EE-B7EF,
|
||||
U+B7F1-B7F3, U+B7F5-B7FB, U+B7FE, U+B802-B806, U+B80A-B80B, U+B80D-B81A, U+B81C-B823,
|
||||
U+B826-B827, U+B829-B82B, U+B82D-B833, U+B836, U+B83A-B83F, U+B841-B853;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-08.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+B854-B85B, U+B85E-B85F, U+B861-B863, U+B865-B86B, U+B86E, U+B870-B8AF, U+B8B1-B8CB,
|
||||
U+B8CD-B8E7, U+B8EA-B8EB, U+B8ED-B8EF, U+B8F1-B8F7, U+B8FA-B903, U+B905-B957, U+B959-B95F,
|
||||
U+B961-B973, U+B975-B977, U+B979-B97B, U+B97D-B983, U+B986, U+B988-B98D, U+B98F-B9AB,
|
||||
U+B9AE-B9AF, U+B9B1-B9B3, U+B9B5-B9BB, U+B9BE, U+B9C0, U+B9C2-B9C7, U+B9CA-B9CB, U+B9CD, U+B9CF,
|
||||
U+B9D2-B9DC, U+B9DF-B9E0, U+B9E2-B9E3, U+B9E6-B9E7, U+B9E9-B9F3, U+B9F6-B9F8, U+B9FB-BA14;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-09.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+BA15-BA37, U+BA3A-BA3B, U+BA3D-BA3F, U+BA41, U+BA43-BA47, U+BA49-BA4A, U+BA4C, U+BA4F-BA53,
|
||||
U+BA55-BA57, U+BA59-BA63, U+BA65-BA6F, U+BA72-BA73, U+BA75-BA77, U+BA79-BA84, U+BA86,
|
||||
U+BA88-BAA7, U+BAAA, U+BAAD-BAAF, U+BAB1-BAB7, U+BAB9-BABA, U+BABC-BB33, U+BB37, U+BB39-BB3A,
|
||||
U+BB3D, U+BB3F-BB48, U+BB4A-BBA3, U+BBA5-BBBF, U+BBC1-BBC7;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-10.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+BBC8-BBF7, U+BBFA-BBFB, U+BBFD-BBFE, U+BC01, U+BC03-BC07, U+BC09-BC0A, U+BC0C-BC0E, U+BC10,
|
||||
U+BC12-BC13, U+BC17, U+BC19-BC1A, U+BC1E-BC28, U+BC2A-BC2F, U+BC32-BC33, U+BC35-BC42,
|
||||
U+BC44-BC83, U+BC85-BC87, U+BC89-BC8B, U+BC8D-BC93, U+BC96, U+BC98-BC99, U+BC9B-BC9F,
|
||||
U+BCA2-BCA3, U+BCA5-BCA7, U+BCA9-BCB2, U+BCB4-BCBB, U+BCBE-BCBF, U+BCC1-BCC3, U+BCC5-BCCC,
|
||||
U+BCCE-BCD0, U+BCD2-BCF3, U+BCF6-BCF7, U+BCF9-BCFB, U+BCFD-BD03, U+BD06, U+BD08, U+BD0A-BD0F,
|
||||
U+BD11-BD23, U+BD25-BD7F, U+BD82-BD83, U+BD85-BD87, U+BD8A-BD8E;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-11.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+BD8F, U+BD91-BD92, U+BD94, U+BD96-BD98, U+BD9A-BDEF, U+BDF1-BE0B, U+BE0D-BE13, U+BE15-BE43,
|
||||
U+BE45-BE47, U+BE49-BE4B, U+BE4D-BE53, U+BE55-BE56, U+BE58, U+BE5A, U+BE5C-BE5F, U+BE62-BE67,
|
||||
U+BE69-BE7B, U+BE7D-BE7F, U+BE81-BE83, U+BE85-BE8B, U+BE8D-BE8E, U+BE91-BED3, U+BED5-BED6,
|
||||
U+BED8-BF07, U+BF09-BF17, U+BF19-BF39;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-12.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+BF3A-BF50, U+BF52-BFAF, U+BFB1-BFCB, U+BFCD-BFCF, U+BFD1-BFD3, U+BFD5-BFDB, U+BFDD-C057,
|
||||
U+C059-C05B, U+C05D-C08F, U+C091-C0AB, U+C0AE-C0AF, U+C0B1-C0B3, U+C0B5, U+C0B7-C0BB,
|
||||
U+C0BE-C0C0, U+C0C2-C0C7, U+C0CA-C0D7, U+C0D9-C0DB, U+C0DE-C0DF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-13.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C0E0-C0F6, U+C0F8-C11B, U+C11F, U+C121-C123, U+C125-C12B, U+C12E, U+C132-C137, U+C13A-C13B,
|
||||
U+C13D-C13F, U+C141-C147, U+C14A, U+C14D-C153, U+C155-C157, U+C159-C167, U+C169-C16F,
|
||||
U+C171-C177, U+C179-C18B, U+C18F, U+C191-C193, U+C195-C19E, U+C1A0, U+C1A2-C1C3, U+C1C5-C1DF,
|
||||
U+C1E1-C217, U+C219-C21B, U+C21D-C21F, U+C221-C227, U+C229-C22A, U+C22C-C26B, U+C26D-C273,
|
||||
U+C275-C27B, U+C27E-C28F, U+C291-C29A;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-14.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C29B-C2A3, U+C2A5-C2A7, U+C2A9-C2AB, U+C2AE-C2B4, U+C2B6, U+C2B8, U+C2BA-C2DB, U+C2DE-C2DF,
|
||||
U+C2E1-C2E2, U+C2E5-C2EB, U+C2EE-C2F0, U+C2F2-C2F5, U+C2F7, U+C2F9-C2FB, U+C2FD-C307,
|
||||
U+C309-C30C, U+C30E-C312, U+C314-C367, U+C369-C36B, U+C36D-C377, U+C379-C37B, U+C37D-C3D7,
|
||||
U+C3D9-C3DB, U+C3DD-C3DF, U+C3E1-C3E7, U+C3E9-C3F3, U+C3F5-C40F, U+C411-C449;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-15.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C44A-C4EF, U+C4F1-C4F3, U+C4F5-C4F7, U+C4F9-C4FF, U+C502-C50B, U+C50D-C527, U+C52A-C52B,
|
||||
U+C52D-C543, U+C546-C547, U+C54B, U+C54D, U+C54F-C553, U+C556, U+C55A-C55D, U+C55F, U+C562-C563,
|
||||
U+C565-C567, U+C569-C56F, U+C572-C573, U+C576-C57B, U+C57E-C586, U+C588-C590, U+C592-C594,
|
||||
U+C596-C5B3, U+C5B6-C5B7, U+C5BA, U+C5BE-C5C3, U+C5CA-C5CD, U+C5CF, U+C5D2-C5D3, U+C5D5-C5D7,
|
||||
U+C5D9-C5E2, U+C5E4-C5EB, U+C5EF, U+C5F1-C5F3, U+C5F5, U+C5F7-C5FB, U+C5FE, U+C602-C604, U+C607,
|
||||
U+C609-C61A, U+C61C-C61E;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-16.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C61F-C623, U+C625-C627, U+C629-C62B, U+C62D, U+C62F-C633, U+C636-C638, U+C63A-C63F,
|
||||
U+C641-C643, U+C645-C650, U+C652-C653, U+C656-C65B, U+C65D-C677, U+C679-C67B, U+C67D-C693,
|
||||
U+C695-C6A8, U+C6AA-C6AF, U+C6B1-C6B3, U+C6B5-C6B7, U+C6B9, U+C6BB-C6BF, U+C6C2, U+C6C4-C6CB,
|
||||
U+C6CD-C6CF, U+C6D1-C6D3, U+C6D5-C6DF, U+C6E1-C6F8, U+C6FA-C703, U+C705-C707, U+C709-C714,
|
||||
U+C716, U+C718, U+C71A-C71F, U+C722-C723, U+C725-C727, U+C729-C73B, U+C73D-C73F, U+C741-C743,
|
||||
U+C745-C74B, U+C74E-C750, U+C752-C757, U+C759-C773, U+C776-C777, U+C779-C77B, U+C77E-C782,
|
||||
U+C786, U+C78B-C78D, U+C78F, U+C792-C793, U+C795, U+C797, U+C799-C79F, U+C7A2, U+C7A4,
|
||||
U+C7A7-C7AB, U+C7AE-C7AF, U+C7B1-C7B3, U+C7B5-C7BB, U+C7BE-C7BF, U+C7C1-C7C7, U+C7C9-C7FA;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-17.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C7FB-C7FF, U+C802-C803, U+C805-C807, U+C809-C80F, U+C812-C814, U+C817-C81B, U+C81E-C81F,
|
||||
U+C821-C82E, U+C830-C837, U+C839-C84B, U+C84D-C86F, U+C872-C873, U+C875-C877, U+C879-C87F,
|
||||
U+C882-C884, U+C886, U+C888-C88A, U+C88D-C8FB, U+C8FE-C8FF, U+C901-C903, U+C905, U+C907-C90B,
|
||||
U+C90E-C910, U+C912-C917, U+C919-C92B, U+C92D-C94F, U+C951-C953, U+C955-C95F, U+C961-C987,
|
||||
U+C98A-C997, U+C999-C99C, U+C99E-C9B3;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-18.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+C9B4-C9BF, U+C9C2-C9C3, U+C9C5-C9C7, U+C9C9-C9CF, U+C9D2, U+C9D4-C9D8, U+C9DB, U+C9DE-C9DF,
|
||||
U+C9E1-C9E3, U+C9E5-C9E6, U+C9E8-C9EB, U+C9ED-C9F7, U+C9F9-CA4C, U+CA4E-CA60, U+CA62-CABB,
|
||||
U+CABE-CB47, U+CB4A-CB59;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-19.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+CB5A-CBE3, U+CBE5-CC0B, U+CC0E-CC27, U+CC2A-CC2B, U+CC2D, U+CC2F, U+CC31-CC37, U+CC39-CC3C,
|
||||
U+CC3F-CC43, U+CC46-CC47, U+CC49-CC4B, U+CC4D-CC53, U+CC55-CC56, U+CC58, U+CC5A-CC97,
|
||||
U+CC9A-CC9B, U+CC9D-CC9F, U+CCA1-CCA7, U+CCAA, U+CCAC, U+CCAE-CCB3, U+CCB5-CCCF, U+CCD1-CCE3,
|
||||
U+CCE5-CD06;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-20.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+CD07, U+CD0A-CD17, U+CD19-CD1C, U+CD1E-CD5B, U+CD5D-CD93, U+CD96-CD97, U+CD99-CD9B,
|
||||
U+CD9D-CDA3, U+CDA6-CDA8, U+CDAA-CDAF, U+CDB1-CDC3, U+CDC5-CDE7, U+CDE9-CE1F, U+CE22-CE34,
|
||||
U+CE36-CE57, U+CE5A-CE5B, U+CE5D-CE5F, U+CE61-CE67, U+CE6A-CE6C, U+CE6E-CE73, U+CE75-CE77,
|
||||
U+CE79-CE8F, U+CE91-CE93, U+CE95-CE97, U+CE99-CEA0, U+CEA2-CEB5;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-21.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+CEB6-CEE3, U+CEE6-CEE7, U+CEE9-CEEB, U+CEED-CEF3, U+CEF5-CEF6, U+CEFA-CEFF, U+CF01-CF07,
|
||||
U+CF09-CF12, U+CF14-CF1B, U+CF1D-CF1F, U+CF21-CF23, U+CF25-CF2B, U+CF2D-CF2F, U+CF31-CF53,
|
||||
U+CF56-CF57, U+CF59-CF5B, U+CF5D-CF63, U+CF65-CFDF, U+CFE1-CFE7, U+CFE9-CFFB, U+CFFD-CFFF,
|
||||
U+D001-D033, U+D035-D05F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-22.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+D060-D06B, U+D06D-D06F, U+D071-D073, U+D075-D07B, U+D07D-D0A3, U+D0A5-D0A7, U+D0A9-D0AB,
|
||||
U+D0AD-D0B3, U+D0B5-D0B6, U+D0B8, U+D0BA-D0BF, U+D0C2-D0C3, U+D0C5-D0C7, U+D0C9-D0CF, U+D0D2,
|
||||
U+D0D4, U+D0D6-D0DB, U+D0DE-D0EC, U+D0EE-D12F, U+D132-D133, U+D135-D13F, U+D141-D14B,
|
||||
U+D14E-D14F, U+D151-D153, U+D155-D15B, U+D15E-D19F, U+D1A2-D1A3, U+D1A5-D1A7, U+D1A9-D1B0,
|
||||
U+D1B2-D1B4, U+D1B6-D1F3, U+D1F5-D215;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-23.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+D216-D22B, U+D22E-D233, U+D235-D240, U+D242-D27F, U+D281-D283, U+D285-D28F, U+D291-D294,
|
||||
U+D296-D29B, U+D29D-D2B7, U+D2BA-D2BB, U+D2BD-D2BF, U+D2C1-D2C7, U+D2C9-D2EF, U+D2F2-D2F3,
|
||||
U+D2F5-D2F7, U+D2F9-D2FF, U+D302-D304, U+D306-D30B, U+D30D, U+D30F, U+D311-D313, U+D315-D31C,
|
||||
U+D31E-D327, U+D32A-D32B, U+D32D-D33C, U+D33E-D37B, U+D37D-D397, U+D39A-D39B, U+D39D-D3B3,
|
||||
U+D3B5-D3B7, U+D3B9-D3BB, U+D3BD-D3C3, U+D3C6-D3C7, U+D3CA-D3CE;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-24.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+D3CF, U+D3D1-D3EB, U+D3EE-D3EF, U+D3F1-D3F3, U+D3F5-D3FB, U+D3FD-D45B, U+D45D-D477,
|
||||
U+D479-D47B, U+D47D-D47F, U+D481, U+D483-D487, U+D489-D48A, U+D48C, U+D48E-D503, U+D505-D507,
|
||||
U+D509-D50B, U+D50D-D53B, U+D53E-D53F, U+D541-D543, U+D545-D54E, U+D550, U+D552-D557,
|
||||
U+D55A-D55B, U+D55D-D55F, U+D561-D567, U+D56A-D56C, U+D56E-D573, U+D576-D577, U+D579-D57F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-25.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+D580-D587, U+D58A-D5A4, U+D5A6-D5C7, U+D5C9-D5CB, U+D5CD-D5CF, U+D5D1-D5D7, U+D5D9-D5DA,
|
||||
U+D5DC-D5E3, U+D5E5-D5EB, U+D5ED-D5F6, U+D5F8-D5FF, U+D601-D603, U+D605-D610, U+D612-D613,
|
||||
U+D616-D637, U+D63A-D63B, U+D63D-D63F, U+D641-D647, U+D64A-D64C, U+D64E-D653, U+D656-D657,
|
||||
U+D659-D65B, U+D65D-D668, U+D66A-D68B, U+D68E-D69E, U+D6A0, U+D6A2-D6A7, U+D6A9-D6C3,
|
||||
U+D6C6-D6C7, U+D6C9-D6D0, U+D6D2-D6E7, U+D6E9-D717, U+D719-D71B, U+D71D-D71F, U+D721-D733,
|
||||
U+D735-D739;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-26.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+D73A-D743, U+D745-D748, U+D74A-D74F, U+D752-D753, U+D755-D757, U+D75A-D75F, U+D762-D764,
|
||||
U+D766-D768, U+D76A-D76F, U+D771-D787, U+D789-D78B, U+D78D-D78F, U+D791-D797, U+D79A-D7A2;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-27.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-0141, U+0143-0393, U+0395-03A2, U+03A4-03A5, U+03A7-03B0, U+03B4-03B7, U+03B9-03BA,
|
||||
U+03BD-03BF, U+03C2, U+03C5, U+03C7-1D61, U+1D63-2001, U+2004-200F, U+2017, U+201B, U+201F,
|
||||
U+2023-2024, U+2028-202F, U+2031, U+2034, U+2036-2038, U+203D-2041, U+2043-2046, U+204A-2050,
|
||||
U+2052-205C, U+205E-207E, U+2083-2089, U+208B-20A8, U+20AA-2102, U+2104-2112, U+2114-2152,
|
||||
U+2154-215F, U+2161-218F, U+2197-21A5, U+21A7-21A8, U+21AB-21B4, U+21B8-21B9, U+21BC-21C3,
|
||||
U+21C5-21C8, U+21CA, U+21CC-21D1, U+21D3-21D4, U+21D6-21E3, U+21E6, U+21E8, U+21EA-21F0,
|
||||
U+21F3-21F5, U+21F7-2204, U+2206-2210, U+2213-2219, U+221B-221C, U+221F, U+2221-2228,
|
||||
U+222B-223B, U+223D-223E, U+2240-2247, U+2249-2251, U+2253-225F, U+2262, U+2266-226A,
|
||||
U+226C-228D, U+228F-2294, U+2296-229D, U+229F-22A1, U+22A4, U+22A6-22C7, U+22C9-22D3,
|
||||
U+22D6-22ED, U+22F0-2300, U+2302-230F, U+2311, U+2313-2315, U+2319-2325, U+2327, U+2329-232A,
|
||||
U+232C-2397, U+2399-2422, U+2424-245F, U+2470, U+2473-24D0, U+24D2-24D7, U+24D9-24FF, U+2501,
|
||||
U+2503, U+2505-2507, U+2509-2513, U+2515-251B, U+251D-254F, U+2551-2570, U+2572-25A0,
|
||||
U+25A2-25A3, U+25A5, U+25AA-25AB, U+25AE-25B1, U+25B4-25B5, U+25B7, U+25B9-25BB, U+25BD, U+25BF,
|
||||
U+25C1-25C6, U+25CA, U+25D1-25DB, U+25DD-25DE, U+25E0-25E6, U+25E8-25E9, U+25EC-25F9,
|
||||
U+25FC-25FF, U+2602-2604, U+2607-260F, U+2611-262F, U+2631-263D, U+263F-2674;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-28.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+2675-2695, U+2697-2698, U+269A-269F, U+26A2-26D3, U+26D5-26EF, U+26F1-26F5, U+26F7-2701,
|
||||
U+2703-2704, U+2706-270A, U+270C-270D, U+270F-2712, U+2714, U+2716, U+2718-271A, U+271C-2724,
|
||||
U+2726-2732, U+2734-2743, U+2745-274F, U+2751-27A3, U+27A5-27F1, U+27F4-27F5, U+27F8-27F9,
|
||||
U+27FB-283E, U+2840-2911, U+2914-2921, U+2923-29C4, U+29C6-29C8, U+29CA-2B05, U+2B08-2B11,
|
||||
U+2B13-2B1F, U+2B23-2B2C, U+2B2E-2B4F, U+2B51-2B6E, U+2B70-2B72, U+2B74-3006, U+3008-300B,
|
||||
U+300E-3130, U+3132-3133, U+3135-3136, U+3138-314A, U+314C-314D, U+3150-315A, U+315C-3162,
|
||||
U+3164-318D, U+318F-3250, U+3252, U+325B-3260, U+3262-326E, U+3271-328C;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-29.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+328D-338E, U+3390-3395, U+3397-339B, U+339F-33A0, U+33A3-33A4, U+33A6-33C9, U+33CB-5147,
|
||||
U+5149-5249;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-30.woff2") format("woff2");
|
||||
unicode-range: U+524A-524C, U+524E-5349, U+534B-5BD0;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-31.woff2") format("woff2");
|
||||
unicode-range: U+5BD2-5F25, U+5F27-5F8B, U+5F8D-64BE;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-32.woff2") format("woff2");
|
||||
unicode-range: U+64BF-6BB4, U+6BB6-6DC3;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-33.woff2") format("woff2");
|
||||
unicode-range: U+6DC4-76CE;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-34.woff2") format("woff2");
|
||||
unicode-range: U+76D2-8109;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-35.woff2") format("woff2");
|
||||
unicode-range: U+810A-8A3A, U+8A3C-8C4E;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-36.woff2") format("woff2");
|
||||
unicode-range: U+8C50-975D, U+975F-9802;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-37.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+9803-ABFF, U+AC02-AC03, U+AC05-AC06, U+AC09-AC0F, U+AC13, U+AC17-AC18, U+AC1A, U+AC1E-AC1F,
|
||||
U+AC21-AC23, U+AC25-AC2B, U+AC2E-AC30, U+AC32-AC6F, U+AC72-AC73, U+AC75-AC76, U+AC79,
|
||||
U+AC7B-AC7F, U+AC82, U+AC84-AC88, U+AC8A-AC8B, U+AC8D-AC9F, U+ACA1-ACA7, U+ACAB, U+ACAD-ACAF,
|
||||
U+ACB1-ACB7, U+ACBA-ACBB, U+ACBE-ACC0, U+ACC2-ACC3, U+ACC5-ACDF, U+ACE2-ACE3, U+ACE5-ACE6,
|
||||
U+ACE9-ACF0, U+ACF2, U+ACF4, U+ACF6-ACFB, U+ACFE-ACFF, U+AD01-AD03, U+AD05-AD10, U+AD12-AD1B,
|
||||
U+AD1D-AD33, U+AD35-AD4F, U+AD51-AD6B, U+AD6E-AD6F, U+AD71-AD72, U+AD76-AD7B, U+AD7E-AD8B,
|
||||
U+AD8D-ADA3, U+ADA5-ADBF, U+ADC1-ADDB, U+ADDD-ADDF, U+ADE1-ADF7, U+ADFA-ADFB, U+ADFD-ADFF,
|
||||
U+AE02-AE07, U+AE0A, U+AE0C-AE2F, U+AE31-AE33, U+AE35-AE37, U+AE39-AE3F, U+AE42, U+AE44-AE49,
|
||||
U+AE4B, U+AE4F, U+AE51-AE53, U+AE55-AE5B, U+AE5E-AE64, U+AE66-AE67, U+AE69-AE6B, U+AE6D-AE77,
|
||||
U+AE79-AE7B, U+AE7D-AEBB, U+AEBD, U+AEBF-AECC, U+AECE-AECF, U+AED1-AED7, U+AED9-AEF3,
|
||||
U+AEF5-AF2B, U+AF2E-AF33, U+AF35-AF41, U+AF43-AF48, U+AF4A-AFB7, U+AFB9-AFBB, U+AFBD-AFBF,
|
||||
U+AFC1-AFC7, U+AFC9-AFD3, U+AFD5-AFE7, U+AFE9-AFEF, U+AFF1-B00B, U+B00D-B00F, U+B011-B013,
|
||||
U+B015-B01B, U+B01E-B043, U+B045-B047, U+B049, U+B04B, U+B04D, U+B04F-B053, U+B055-B056,
|
||||
U+B058-B05C, U+B05E-B07B, U+B07D-B07F, U+B081-B08B, U+B08D-B097, U+B09A-B09B, U+B09D-B09F,
|
||||
U+B0A2-B0A7, U+B0AA, U+B0AF-B0B0, U+B0B2, U+B0B5-B0B7, U+B0B9-B0BB, U+B0BD-B0C3, U+B0C6-B0C7,
|
||||
U+B0C9-B0CF, U+B0D1-B0E4, U+B0E6-B107, U+B10A-B10B, U+B10D-B10F, U+B111-B112, U+B114-B117,
|
||||
U+B119-B11A, U+B11C-B122, U+B125-B127, U+B129-B133, U+B135-B136, U+B139-B13F, U+B141-B143,
|
||||
U+B145-B14F, U+B151-B154, U+B156-B177, U+B17A-B17B, U+B17D-B191, U+B194-B1A7, U+B1A9-B203,
|
||||
U+B206-B207, U+B209-B20B, U+B20D-B213, U+B216, U+B218-B21F, U+B221-B257, U+B259-B25B,
|
||||
U+B25D-B267, U+B269-B273, U+B275-B288, U+B28A-B28F, U+B291-B293, U+B295-B297, U+B299,
|
||||
U+B29B-B29F, U+B2A1-B2A4, U+B2A7-B2AB, U+B2AD-B2C7, U+B2CA-B2CB, U+B2CD-B2CF, U+B2D1-B2D7,
|
||||
U+B2DA, U+B2DC, U+B2DE-B2E3, U+B2E6-B2E7, U+B2E9-B2EA, U+B2EF-B2F3, U+B2F6-B2F8, U+B2FA,
|
||||
U+B2FC-B2FE, U+B301-B303, U+B305-B30F, U+B312, U+B314-B353, U+B356-B357, U+B359-B35B,
|
||||
U+B35D-B35E, U+B360-B363, U+B365-B366, U+B368, U+B36A-B36D, U+B36F, U+B372-B377, U+B379-B38B,
|
||||
U+B38D-B3C3, U+B3C6-B3C7, U+B3C9-B3CA, U+B3CD, U+B3CF-B3D3, U+B3D6-B3D8, U+B3DA-B3FB,
|
||||
U+B3FD-B40F, U+B411-B417, U+B419-B41B, U+B41D-B41F, U+B421-B427, U+B42A-B44F, U+B451-B453,
|
||||
U+B455-B457, U+B459-B45F, U+B462-B464, U+B466-B46B, U+B46D-B47F, U+B481-B4A3, U+B4A5-B4B6,
|
||||
U+B4B8-B4C7, U+B4C9-B4DB, U+B4DE-B4DF, U+B4E1-B4E2, U+B4E5, U+B4E7-B4EB, U+B4EE, U+B4F0,
|
||||
U+B4F2-B513, U+B516-B517, U+B519-B528, U+B52B-B52F, U+B532-B533, U+B535-B537, U+B539-B544,
|
||||
U+B546-B54B, U+B54D-B54F, U+B551-B59F, U+B5A1-B5A3, U+B5A5-B5A7, U+B5A9-B5B3, U+B5B5-B5BA,
|
||||
U+B5BD-B5BF, U+B5C1-B5C3, U+B5C5-B5CB, U+B5CD-B5CF, U+B5D1-B60F, U+B612-B69B, U+B69E-B6AA,
|
||||
U+B6AC-B6B0, U+B6B2-B6EF, U+B6F1-B6F3, U+B6F5-B6F7, U+B6F9-B6FF, U+B701-B727, U+B729-B72B,
|
||||
U+B72D-B72F, U+B731-B737, U+B73A, U+B73C-B743, U+B745-B747, U+B749-B75F, U+B761-B77B,
|
||||
U+B77E-B77F, U+B781-B783, U+B785-B78B, U+B78E-B78F, U+B792-B796, U+B79A-B79B, U+B79D-B7A7,
|
||||
U+B7AA, U+B7AD-B7B4, U+B7B6-B7C8, U+B7CA-B7EB, U+B7EE-B7EF, U+B7F1-B7F3, U+B7F5-B7FB, U+B7FE,
|
||||
U+B802-B806, U+B80A-B80B, U+B80D-B81A, U+B81C-B823, U+B826-B827, U+B829-B82B, U+B82D-B833,
|
||||
U+B836, U+B83A-B83F, U+B841-B85B, U+B85E-B85F, U+B861-B863, U+B865-B86B, U+B86E, U+B870-B8AF,
|
||||
U+B8B1-B8CB, U+B8CD-B8E7, U+B8EA-B8EB, U+B8ED-B8EF, U+B8F1-B8F7, U+B8FA-B903, U+B905-B957,
|
||||
U+B959-B95F, U+B961-B973, U+B975-B977, U+B979-B97B, U+B97D-B983, U+B986, U+B988-B98D,
|
||||
U+B98F-B9AB, U+B9AE-B9AF, U+B9B1-B9B3, U+B9B5-B9BB, U+B9BE, U+B9C0, U+B9C2-B9C7, U+B9CA-B9CB,
|
||||
U+B9CD, U+B9CF, U+B9D2-B9DC, U+B9DF-B9E0, U+B9E2-B9E3, U+B9E6-B9E7, U+B9E9-B9F3, U+B9F6-B9F8,
|
||||
U+B9FB-BA37, U+BA3A-BA3B, U+BA3D-BA3F, U+BA41, U+BA43-BA47, U+BA49-BA4A, U+BA4C, U+BA4F-BA53,
|
||||
U+BA55-BA57, U+BA59-BA63, U+BA65-BA6F, U+BA72-BA73, U+BA75-BA77, U+BA79-BA84, U+BA86,
|
||||
U+BA88-BAA7, U+BAAA, U+BAAD-BAAF, U+BAB1-BAB7, U+BAB9-BABA, U+BABC-BB33, U+BB37, U+BB39-BB3A,
|
||||
U+BB3D, U+BB3F-BB48, U+BB4A-BBA3, U+BBA5-BBBF, U+BBC1-BBF7, U+BBFA-BBFB, U+BBFD-BBFE, U+BC01,
|
||||
U+BC03-BC07, U+BC09-BC0A, U+BC0C-BC0E, U+BC10, U+BC12-BC13, U+BC17, U+BC19-BC1A, U+BC1E-BC28,
|
||||
U+BC2A-BC2F, U+BC32-BC33, U+BC35-BC42, U+BC44-BC83, U+BC85-BC87, U+BC89-BC8B, U+BC8D-BC93,
|
||||
U+BC96, U+BC98-BC99, U+BC9B-BC9F, U+BCA2-BCA3, U+BCA5-BCA7, U+BCA9-BCB2, U+BCB4-BCBB,
|
||||
U+BCBE-BCBF, U+BCC1-BCC3, U+BCC5-BCCC, U+BCCE-BCD0, U+BCD2-BCF3, U+BCF6-BCF7, U+BCF9-BCFB,
|
||||
U+BCFD-BD03, U+BD06, U+BD08, U+BD0A-BD0F, U+BD11-BD23, U+BD25-BD7F, U+BD82-BD83, U+BD85-BD87,
|
||||
U+BD8A-BD8F, U+BD91-BD92, U+BD94, U+BD96-BD98, U+BD9A-BDEF, U+BDF1-BE0B, U+BE0D-BE13,
|
||||
U+BE15-BE43, U+BE45-BE47, U+BE49-BE4B, U+BE4D-BE53, U+BE55-BE56, U+BE58, U+BE5A, U+BE5C-BE5F,
|
||||
U+BE62-BE67, U+BE69-BE7B, U+BE7D-BE7F, U+BE81-BE83, U+BE85-BE8B, U+BE8D-BE8E, U+BE91-BED3,
|
||||
U+BED5-BED6, U+BED8-BF07, U+BF09-BF17, U+BF19-BF50, U+BF52-BFAF, U+BFB1-BFCB, U+BFCD-BFCF,
|
||||
U+BFD1-BFD3, U+BFD5-BFDB, U+BFDD-C057, U+C059-C05B, U+C05D-C08F, U+C091-C0AB, U+C0AE-C0AF,
|
||||
U+C0B1-C0B3, U+C0B5, U+C0B7-C0BB, U+C0BE-C0C0, U+C0C2-C0C7, U+C0CA-C0D7, U+C0D9-C0DB,
|
||||
U+C0DE-C0F6, U+C0F8-C11B, U+C11F, U+C121-C123, U+C125-C12B, U+C12E, U+C132-C137, U+C13A-C13B,
|
||||
U+C13D-C13F, U+C141-C147, U+C14A, U+C14D-C153, U+C155-C157, U+C159-C167, U+C169-C16F,
|
||||
U+C171-C177, U+C179-C18B, U+C18F, U+C191-C193, U+C195-C19E, U+C1A0, U+C1A2-C1C3, U+C1C5-C1DF,
|
||||
U+C1E1-C217, U+C219-C21B, U+C21D-C21F, U+C221-C227, U+C229-C22A, U+C22C-C26B, U+C26D-C273,
|
||||
U+C275-C27B, U+C27E-C28F, U+C291-C2A3, U+C2A5-C2A7, U+C2A9-C2AB, U+C2AE-C2B4, U+C2B6, U+C2B8,
|
||||
U+C2BA-C2DB, U+C2DE-C2DF, U+C2E1-C2E2, U+C2E5-C2EB, U+C2EE-C2F0, U+C2F2-C2F5, U+C2F7,
|
||||
U+C2F9-C2FB, U+C2FD-C307, U+C309-C30C, U+C30E-C312, U+C314-C367, U+C369-C36B, U+C36D-C377,
|
||||
U+C379-C37B, U+C37D-C3D7, U+C3D9-C3DB, U+C3DD-C3DF, U+C3E1-C3E7, U+C3E9-C3F3, U+C3F5-C40F,
|
||||
U+C411-C4EF, U+C4F1-C4F3, U+C4F5-C4F7, U+C4F9-C4FF, U+C502-C50B, U+C50D-C527, U+C52A-C52B,
|
||||
U+C52D-C543, U+C546-C547, U+C54B, U+C54D, U+C54F-C553, U+C556, U+C55A-C55D, U+C55F, U+C562-C563,
|
||||
U+C565-C567, U+C569-C56F, U+C572-C573, U+C576-C57B, U+C57E-C586, U+C588-C590, U+C592-C594,
|
||||
U+C596-C5B3, U+C5B6-C5B7, U+C5BA, U+C5BE-C5C3, U+C5CA-C5CD, U+C5CF, U+C5D2-C5D3, U+C5D5-C5D7,
|
||||
U+C5D9-C5E2, U+C5E4-C5EB, U+C5EF, U+C5F1-C5F3, U+C5F5, U+C5F7-C5FB, U+C5FE, U+C602-C604, U+C607,
|
||||
U+C609-C61A, U+C61C-C623, U+C625-C627, U+C629-C62B, U+C62D, U+C62F-C633, U+C636-C638,
|
||||
U+C63A-C63F, U+C641-C643, U+C645-C650, U+C652-C653, U+C656-C65B, U+C65D-C677, U+C679-C67B,
|
||||
U+C67D-C693, U+C695-C6A8, U+C6AA-C6AF, U+C6B1-C6B3, U+C6B5-C6B7, U+C6B9, U+C6BB-C6BF, U+C6C2,
|
||||
U+C6C4-C6CB, U+C6CD-C6CF, U+C6D1-C6D3, U+C6D5-C6DF, U+C6E1-C6F8, U+C6FA-C703, U+C705-C707,
|
||||
U+C709-C714, U+C716, U+C718, U+C71A-C71F, U+C722-C723, U+C725-C727, U+C729-C73B, U+C73D-C73F,
|
||||
U+C741-C743, U+C745-C74B, U+C74E-C750, U+C752-C757, U+C759-C773, U+C776-C777, U+C779-C77B,
|
||||
U+C77E-C782, U+C786, U+C78B-C78D, U+C78F, U+C792-C793, U+C795, U+C797, U+C799-C79F, U+C7A2,
|
||||
U+C7A4, U+C7A7-C7AB, U+C7AE-C7AF, U+C7B1-C7B3, U+C7B5-C7BB, U+C7BE-C7BF, U+C7C1-C7C7,
|
||||
U+C7C9-C7FF, U+C802-C803, U+C805-C807, U+C809-C80F, U+C812-C814, U+C817-C81B, U+C81E-C81F,
|
||||
U+C821-C82E, U+C830-C837, U+C839-C84B, U+C84D-C86F, U+C872-C873, U+C875-C877, U+C879-C87F,
|
||||
U+C882-C884, U+C886, U+C888-C88A, U+C88D-C8FB, U+C8FE-C8FF, U+C901-C903, U+C905, U+C907-C90B,
|
||||
U+C90E-C910, U+C912-C917, U+C919-C92B, U+C92D-C94F, U+C951-C953, U+C955-C95F, U+C961-C987,
|
||||
U+C98A-C997, U+C999-C99C, U+C99E-C9BF, U+C9C2-C9C3, U+C9C5-C9C7, U+C9C9-C9CF, U+C9D2,
|
||||
U+C9D4-C9D8, U+C9DB, U+C9DE-C9DF, U+C9E1-C9E3, U+C9E5-C9E6, U+C9E8-C9EB, U+C9ED-C9F7,
|
||||
U+C9F9-CA4C, U+CA4E-CA60, U+CA62-CABB, U+CABE-CB47, U+CB4A-CBE3, U+CBE5-CC0B, U+CC0E-CC27,
|
||||
U+CC2A-CC2B, U+CC2D, U+CC2F, U+CC31-CC37, U+CC39-CC3C, U+CC3F-CC43, U+CC46-CC47, U+CC49-CC4B,
|
||||
U+CC4D-CC53, U+CC55-CC56, U+CC58, U+CC5A-CC97, U+CC9A-CC9B, U+CC9D-CC9F, U+CCA1-CCA7, U+CCAA,
|
||||
U+CCAC, U+CCAE-CCB3, U+CCB5-CCCF, U+CCD1-CCE3, U+CCE5-CD07, U+CD0A-CD17, U+CD19-CD1C,
|
||||
U+CD1E-CD5B, U+CD5D-CD93, U+CD96-CD97, U+CD99-CD9B, U+CD9D-CDA3, U+CDA6-CDA8, U+CDAA-CDAF,
|
||||
U+CDB1-CDC3, U+CDC5-CDE7, U+CDE9-CE1F, U+CE22-CE34, U+CE36-CE57, U+CE5A-CE5B, U+CE5D-CE5F,
|
||||
U+CE61-CE67, U+CE6A-CE6C, U+CE6E-CE73, U+CE75-CE77, U+CE79-CE8F, U+CE91-CE93, U+CE95-CE97,
|
||||
U+CE99-CEA0, U+CEA2-CEE3, U+CEE6-CEE7, U+CEE9-CEEB, U+CEED-CEF3, U+CEF5-CEF6, U+CEFA-CEFF,
|
||||
U+CF01-CF07, U+CF09-CF12, U+CF14-CF1B, U+CF1D-CF1F, U+CF21-CF23, U+CF25-CF2B, U+CF2D-CF2F,
|
||||
U+CF31-CF53, U+CF56-CF57, U+CF59-CF5B, U+CF5D-CF63, U+CF65-CFDF, U+CFE1-CFE7, U+CFE9-CFFB,
|
||||
U+CFFD-CFFF, U+D001-D033, U+D035-D06B, U+D06D-D06F, U+D071-D073, U+D075-D07B, U+D07D-D0A3,
|
||||
U+D0A5-D0A7, U+D0A9-D0AB, U+D0AD-D0B3, U+D0B5-D0B6, U+D0B8, U+D0BA-D0BF, U+D0C2-D0C3,
|
||||
U+D0C5-D0C7, U+D0C9-D0CF, U+D0D2, U+D0D4, U+D0D6-D0DB, U+D0DE-D0EC, U+D0EE-D12F, U+D132-D133,
|
||||
U+D135-D13F, U+D141-D14B, U+D14E-D14F, U+D151-D153, U+D155-D15B, U+D15E-D19F, U+D1A2-D1A3,
|
||||
U+D1A5-D1A7, U+D1A9-D1B0, U+D1B2-D1B4, U+D1B6-D1F3, U+D1F5-D22B, U+D22E-D233, U+D235-D240,
|
||||
U+D242-D27F, U+D281-D283, U+D285-D28F, U+D291-D294, U+D296-D29B, U+D29D-D2B7, U+D2BA-D2BB,
|
||||
U+D2BD-D2BF, U+D2C1-D2C7, U+D2C9-D2EF, U+D2F2-D2F3, U+D2F5-D2F7, U+D2F9-D2FF, U+D302-D304,
|
||||
U+D306-D30B, U+D30D, U+D30F, U+D311-D313, U+D315-D31C, U+D31E-D327, U+D32A-D32B, U+D32D-D33C,
|
||||
U+D33E-D37B, U+D37D-D397, U+D39A-D39B, U+D39D-D3B3, U+D3B5-D3B7, U+D3B9-D3BB, U+D3BD-D3C3,
|
||||
U+D3C6-D3C7, U+D3CA-D3CF, U+D3D1-D3EB, U+D3EE-D3EF, U+D3F1-D3F3, U+D3F5-D3FB, U+D3FD-D45B,
|
||||
U+D45D-D477, U+D479-D47B, U+D47D-D47F, U+D481, U+D483-D487, U+D489-D48A, U+D48C, U+D48E-D503,
|
||||
U+D505-D507, U+D509-D50B, U+D50D-D53B, U+D53E-D53F, U+D541-D543, U+D545-D54E, U+D550,
|
||||
U+D552-D557, U+D55A-D55B, U+D55D-D55F, U+D561-D567, U+D56A-D56C, U+D56E-D573, U+D576-D577,
|
||||
U+D579-D587, U+D58A-D5A4, U+D5A6-D5C7, U+D5C9-D5CB, U+D5CD-D5CF, U+D5D1-D5D7, U+D5D9-D5DA,
|
||||
U+D5DC-D5E3, U+D5E5-D5EB, U+D5ED-D5F6, U+D5F8-D5FF, U+D601-D603, U+D605-D610, U+D612-D613,
|
||||
U+D616-D637, U+D63A-D63B, U+D63D-D63F, U+D641-D647, U+D64A-D64C, U+D64E-D653, U+D656-D657,
|
||||
U+D659-D65B, U+D65D-D668, U+D66A-D68B, U+D68E-D69E, U+D6A0, U+D6A2-D6A7, U+D6A9-D6C3,
|
||||
U+D6C6-D6C7, U+D6C9-D6D0, U+D6D2-D6E7, U+D6E9-D717, U+D719-D71B, U+D71D-D71F, U+D721-D733,
|
||||
U+D735-D743, U+D745-D748, U+D74A-D74F, U+D752-D753, U+D755-D757, U+D75A-D75F, U+D762-D764,
|
||||
U+D766-D768, U+D76A-D76F, U+D771-D787, U+D789-D78B, U+D78D-D78F, U+D791-D797, U+D79A-D7A2,
|
||||
U+D7A4-FA53;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Noto Sans KR";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./noto-sans-kr-38.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+FA54-FE0E, U+FE10-FF07, U+FF0A, U+FF0C, U+FF0E-FF19, U+FF1B-FF5D, U+FF5F-1F16F, U+1F171-1F39A,
|
||||
U+1F39C-1F3A7, U+1F3A9-1F3D6, U+1F3D8-1F3DD, U+1F3DF, U+1F3E1-1F462, U+1F464-1F4BD,
|
||||
U+1F4BF-1F4C0, U+1F4C3, U+1F4C6-1F4CA, U+1F4CC, U+1F4CE, U+1F4D2-1F4E4, U+1F4E6-1F50C,
|
||||
U+1F50E-1F511, U+1F514-1F58B, U+1F58D-1F5A8, U+1F5AA-1F5BB, U+1F5BD-1F5C3, U+1F5C5-1F5D0,
|
||||
U+1F5D2-1F5F9, U+1F5FB-1F6A6, U+1F6A8-1F6E2, U+1F6E4-1F9C9, U+1F9CB-1F9E8, U+1F9EA-1F9EC,
|
||||
U+1F9EE-1F9F0, U+1F9F2-1FAA7, U+1FAA9-30EDE;
|
||||
}
|
||||
@@ -1,83 +1,74 @@
|
||||
/* =============================================================================
|
||||
* ui_template_theme.css
|
||||
* 글로벌 디자인 테마 및 CSS 변수 (라이트/다크)
|
||||
* 글로벌 디자인 테마 및 CSS 변수 (라이트/다크) — 프로그램 전체 한 벌(대시보드 값)
|
||||
*
|
||||
* 출처: .agent/design.md — "Wiza" 스타일 레퍼런스
|
||||
* 원칙:
|
||||
* - 모든 색상/간격/폰트는 이 파일의 CSS 변수(var(--...))로만 참조.
|
||||
* - 하드코딩된 Hex/px 값을 컴포넌트에 직접 입력 금지 (frontend.md §1).
|
||||
* - 8px radius가 시그니처. pill/tag만 --radius-pills 사용.
|
||||
* - 값은 이 파일 두 블록(밝음 · 어두움)에만 — 대시보드 `--b01-*` 는 여기 값의 별칭.
|
||||
* ========================================================================== */
|
||||
|
||||
/* 글꼴 파일(Noto Sans KR · SIL OFL 1.1) — 모든 PC 가 같은 글꼴 · CAD 앱도 이 파일을 읽음 */
|
||||
@import url("./fonts/noto_sans_kr.css");
|
||||
|
||||
:root {
|
||||
/* 브라우저 기본 부품(체크 · 색 고르개 · 펼침 목록 · 스크롤 막대)이 테마를 따름 */
|
||||
color-scheme: light;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 1. 색상 토큰 (Colors) — 바이올렛 단색 팔레트
|
||||
* 1. 색상 토큰 (Colors) — 중립 회색 + 초록 강조
|
||||
* ------------------------------------------------------------------------ */
|
||||
--color-deep-iris: #26114a; /* 헤드라인, filled 버튼 배경 (24px 이하) */
|
||||
--color-plum-velvet: #312749; /* 네비 텍스트, 보조 제목, 카드 타이틀 */
|
||||
--color-royal-amethyst: #3e0079; /* 링크, 포커스 링, 아이콘 하이라이트 */
|
||||
--color-mist-violet: #edecff; /* 소프트 하이라이트 배경, 태그 배경 */
|
||||
--color-lavender-wash: #b99aff; /* 그라디언트 스톱, 히어로 분위기 */
|
||||
--color-twilight-beam: #cf8aff; /* 멀티스톱 장식 그라디언트 시작색 */
|
||||
|
||||
/* 중립 (Neutrals) */
|
||||
--color-canvas: #ffffff; /* 페이지 배경, 카드 표면, 입력 필드 */
|
||||
--color-paper: #f6f7fa; /* 미묘한 표면 리프트, 테이블 교차행 */
|
||||
--color-mist: #e6e2e3; /* 헤어라인 보더, 구분선 */
|
||||
--color-canvas: #ffffff; /* 카드 표면, 입력 필드 */
|
||||
--color-paper: #f6f7f9; /* 페이지 바탕, 테이블 교차행 */
|
||||
--color-mist: #e6e8ec; /* 헤어라인 보더, 구분선 */
|
||||
--color-smoke: #c1c7cf; /* 비활성 상태, 스켈레톤 플레이스홀더 */
|
||||
--color-ash: #9491a1; /* 뮤트 헬퍼 텍스트, placeholder */
|
||||
--color-slate: #615e6e; /* 보조 본문 텍스트, 설명 */
|
||||
--color-charcoal: #333333; /* 본문 텍스트, 링크, 아이콘 스트로크 */
|
||||
--color-ash: #9ca3af; /* 옅은 회색 글(장식) — 읽을 글은 --color-text-muted */
|
||||
--color-slate: #6b7280; /* 보조 본문 텍스트, 설명 */
|
||||
--color-charcoal: #1f2328; /* 본문 텍스트, 아이콘 스트로크 */
|
||||
--color-carbon: #222222; /* 다크 UI 요소, 니어블랙 액센트 */
|
||||
--color-ink: #000000; /* 최대 대비 텍스트/보더 */
|
||||
|
||||
/* 상태 색상 (Status) — 바이올렛 계열 유지, 필수 기능색만 최소 추가 */
|
||||
--color-success: #1f8a4c; /* 성공/승인 */
|
||||
--color-warning: #b8860b; /* 경고 (design.md 별색 #f5b800 계열) */
|
||||
--color-danger: #c0392b; /* 에러/삭제 */
|
||||
/* 패널 접기/펼치기 손잡이 테두리 — 배경에 묻히지 않는 강조색(라이트: 파랑, 다크: 노랑) */
|
||||
--panel-handle-border: #2563eb;
|
||||
--color-info: var(--color-royal-amethyst);
|
||||
/* 강조 (Accent) */
|
||||
--color-accent: #1f7a4d; /* 링크 · 주 액션 · 고른 것 */
|
||||
--color-accent-soft: #eaf5ee; /* 고른 줄 · 태그 바탕 */
|
||||
--color-hover: #f3f4f6; /* 줄 · 메뉴 hover 바탕 */
|
||||
|
||||
/* 그라디언트 (Gradient System) — 항상 흰 표면 뒤에만 배치, 텍스트 직상단 금지 */
|
||||
--gradient-lavender-wash: linear-gradient(
|
||||
90deg,
|
||||
rgb(185, 154, 255),
|
||||
rgb(125, 67, 255) 10%,
|
||||
rgba(125, 67, 255, 0)
|
||||
);
|
||||
--gradient-twilight-beam: linear-gradient(
|
||||
to right,
|
||||
rgb(207, 138, 255),
|
||||
rgb(255, 102, 193),
|
||||
rgb(255, 173, 116),
|
||||
rgb(170, 129, 255)
|
||||
);
|
||||
--gradient-section-tint: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-canvas),
|
||||
var(--color-mist-violet)
|
||||
);
|
||||
/* 상태 색상 (Status) — 성공 램프는 ✓ 를 같이 띄움(강조색과 같은 초록 계열) */
|
||||
--color-success: #1f8a4c; /* 성공/승인 */
|
||||
--color-warning: #b45309; /* 경고 */
|
||||
--color-danger: #c0392b; /* 에러/삭제 글자 · 선 */
|
||||
--color-danger-fill: #e5484d; /* 위험 채운 바탕(알림 점 · 배지) */
|
||||
--color-info: #2e6fb5;
|
||||
/* 패널 접기/펼치기 손잡이 테두리 — 강조색 */
|
||||
--panel-handle-border: var(--color-accent);
|
||||
--color-scrim: rgba(16, 24, 40, 0.4); /* 모달 · 서랍 뒤 가림막 */
|
||||
|
||||
/* 옛 이름 — 새 코드 금지 · 새 값의 별칭 */
|
||||
--color-deep-iris: var(--color-text);
|
||||
--color-plum-velvet: var(--color-text);
|
||||
--color-royal-amethyst: var(--color-accent);
|
||||
--color-mist-violet: var(--color-accent-soft);
|
||||
--color-lavender-wash: var(--color-accent);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 2. 의미 기반 별칭 (Semantic Aliases)
|
||||
* 컴포넌트는 가급적 아래 별칭을 사용해 라이트/다크 전환에 대응.
|
||||
* ------------------------------------------------------------------------ */
|
||||
--color-bg: var(--color-canvas); /* 페이지 배경 */
|
||||
--color-surface: var(--color-paper); /* 카드/패널 표면 */
|
||||
--color-surface-raised: var(--color-canvas); /* 떠 있는 표면 */
|
||||
--color-bg: var(--color-paper); /* 페이지 바탕 */
|
||||
--color-surface: var(--color-paper); /* 판 바탕 · 표 머리 · 얼룩줄 */
|
||||
--color-surface-raised: var(--color-canvas); /* 카드 · 떠 있는 표면 */
|
||||
--color-surface-sunken: var(--color-hover);
|
||||
--color-border: var(--color-mist); /* 기본 보더 */
|
||||
--color-text: var(--color-deep-iris); /* 기본 본문 (제목 강조) */
|
||||
--color-border-strong: #cfd4db; /* 좌측 카드 외곽 · 표 머리 밑줄 */
|
||||
--color-text: var(--color-charcoal); /* 기본 글 · 제목 */
|
||||
--color-text-body: var(--color-charcoal); /* 일반 본문 */
|
||||
--color-text-secondary: var(--color-slate); /* 보조 텍스트 */
|
||||
--color-text-muted: var(--color-ash); /* 뮤트/placeholder */
|
||||
--color-primary: var(--color-deep-iris); /* 주 액션 배경 */
|
||||
--color-primary-text: var(--color-canvas); /* 주 액션 위 텍스트 */
|
||||
--color-accent: var(--color-royal-amethyst); /* 링크/액센트 */
|
||||
--color-focus-ring: var(--color-royal-amethyst);
|
||||
--color-text-muted: var(--color-slate); /* 뮤트/placeholder */
|
||||
--color-primary: var(--color-accent); /* 주 액션 배경 */
|
||||
--color-primary-text: #ffffff; /* 주 액션 위 텍스트 */
|
||||
--color-primary-hover: color-mix(in srgb, var(--color-primary) 88%, #000);
|
||||
--color-focus-ring: var(--color-accent);
|
||||
|
||||
/* 차트 시리즈 색상 (CPU/메모리/디스크 등 구분용) */
|
||||
--color-chart-0: #5b8def; /* 파랑 — CPU */
|
||||
@@ -88,12 +79,10 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 3. 타이포그래피 (Typography)
|
||||
* ------------------------------------------------------------------------ */
|
||||
/* 폰트 패밀리 — 한글은 Noto Sans KR 우선 (resources/fonts) */
|
||||
--font-display:
|
||||
"Britti Sans", "Plus Jakarta Sans", "Noto Sans KR", ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
--font-body:
|
||||
"Inter", "Noto Sans KR", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
/* 폰트 패밀리 — 한 토큰 · Noto Sans KR 는 실은 파일(fonts/) · 뒤는 파일을 못 받을 때만 */
|
||||
--font-ui: "Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
||||
--font-body: var(--font-ui);
|
||||
--font-display: var(--font-ui);
|
||||
--font-mono: "JetBrains Mono", "Consolas", ui-monospace, monospace;
|
||||
|
||||
/* 스케일 (px 고정 — design.md 명세) */
|
||||
@@ -152,7 +141,7 @@
|
||||
--radius-3xl: 24px; /* large 컨테이너 */
|
||||
--radius-pills: 1440px; /* pill/tag/캡슐 네비 전용 */
|
||||
|
||||
--radius-cards: var(--radius-lg);
|
||||
--radius-cards: 10px;
|
||||
--radius-buttons: var(--radius-lg);
|
||||
--radius-inputs: var(--radius-lg);
|
||||
--radius-icons: var(--radius-lg);
|
||||
@@ -174,19 +163,10 @@
|
||||
--pill-font-size-lg: var(--text-body-sm);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 6. 그림자 (Shadows) — 블루틴트, 서브틀 (최대 32px blur / 6% opacity)
|
||||
* 6. 그림자 (Shadows) — 떠 있는 것(메뉴 · 모달 · 툴팁)만 · 카드는 선만
|
||||
* ------------------------------------------------------------------------ */
|
||||
--shadow-sm:
|
||||
rgba(18, 55, 105, 0.08) 0px 2px 4px 0px, rgba(18, 55, 105, 0.04) 0px 1px 1px 0px,
|
||||
rgba(18, 55, 105, 0.08) 0px 0px 0px 1px;
|
||||
--shadow-lg:
|
||||
rgba(14, 59, 101, 0.06) 0px 32px 24px -12px, rgba(14, 59, 101, 0.01) 0px 11px 4px 0px,
|
||||
rgba(14, 59, 101, 0.02) 0px 6px 4px 0px, rgba(14, 59, 101, 0.03) 0px 3px 3px 0px,
|
||||
rgba(14, 59, 101, 0.04) 0px 1px 1px 0px;
|
||||
--shadow-feature:
|
||||
rgba(114, 49, 255, 0.32) 0px -6px 20px 0px inset, rgba(47, 1, 151, 0.12) 0px 11px 12px 0px,
|
||||
rgba(47, 1, 151, 0.12) 0px 3px 3px 0px, rgba(47, 1, 151, 0.12) 0px 1px 1px 0px,
|
||||
rgba(47, 1, 151, 0.08) 0px 0px 0px 1px;
|
||||
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-lg: 0 8px 24px rgba(16, 24, 40, 0.12);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 7. 레이아웃 (Layout)
|
||||
@@ -296,44 +276,34 @@
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 다크 모드 (Dark) — 라이트 우선 브랜드이나 CAD 뷰어 야간 작업 대응
|
||||
* design.md는 light 브랜드이므로, 다크는 중립 표면을 어둡게 반전하고
|
||||
* 바이올렛 액센트는 명도를 올려 대비 확보.
|
||||
* 다크 모드 (Dark) — 중립 회색 바탕 · 강조는 명도를 올려 대비 확보
|
||||
* 의미 별칭(bg · surface · text …)은 :root 식 그대로라 여기서는 값만 바꿈.
|
||||
* ========================================================================== */
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-canvas: #16121f;
|
||||
--color-paper: #1e1930;
|
||||
--color-mist: #322a45;
|
||||
--color-smoke: #4a4160;
|
||||
--color-ash: #8b86a0;
|
||||
--color-slate: #b3aec6;
|
||||
--color-charcoal: #e4e0f0;
|
||||
--color-carbon: #f0edf8;
|
||||
--color-canvas: #1b1e23;
|
||||
--color-paper: #121417;
|
||||
--color-mist: #2c3038;
|
||||
--color-smoke: #4a4f58;
|
||||
--color-ash: #7d848e;
|
||||
--color-slate: #9aa1ab;
|
||||
--color-charcoal: #e6e8eb;
|
||||
--color-carbon: #f0f1f3;
|
||||
--color-ink: #ffffff;
|
||||
|
||||
--color-deep-iris: #d8ccff;
|
||||
--color-plum-velvet: #c4b8e6;
|
||||
--color-royal-amethyst: #b99aff;
|
||||
--color-mist-violet: #2a2140;
|
||||
|
||||
--color-bg: var(--color-canvas);
|
||||
--color-surface: var(--color-paper);
|
||||
--color-surface-raised: #251f38;
|
||||
--color-border: var(--color-mist);
|
||||
--color-text: var(--color-deep-iris);
|
||||
--color-text-body: var(--color-charcoal);
|
||||
--color-text-secondary: var(--color-slate);
|
||||
--color-text-muted: var(--color-ash);
|
||||
--color-primary: var(--color-royal-amethyst);
|
||||
--color-primary-text: #16121f;
|
||||
--color-accent: var(--color-lavender-wash);
|
||||
--color-focus-ring: var(--color-lavender-wash);
|
||||
--color-accent: #3fae75;
|
||||
--color-accent-soft: #173326;
|
||||
--color-hover: #242830;
|
||||
--color-border-strong: #3b414b;
|
||||
--color-primary-text: #0e1512;
|
||||
--color-primary-hover: color-mix(in srgb, var(--color-primary) 85%, #fff);
|
||||
|
||||
--color-success: #3fbb6f;
|
||||
--color-warning: #e0b64a;
|
||||
--color-warning: #f5a524;
|
||||
--color-danger: #e8695a;
|
||||
--panel-handle-border: #e0b64a;
|
||||
--color-danger-fill: #f06b6f;
|
||||
--color-info: #6aa6e0;
|
||||
--color-scrim: rgba(0, 0, 0, 0.55);
|
||||
--map-route-locked: #facc15;
|
||||
--map-route-node: #4ade80;
|
||||
|
||||
@@ -341,43 +311,38 @@
|
||||
--shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px;
|
||||
}
|
||||
|
||||
/* 시스템 설정 기반 자동 다크 (사용자가 명시적으로 data-theme를 안 준 경우) */
|
||||
/* 시스템 설정 기반 자동 다크 (사용자가 명시적으로 data-theme를 안 준 경우) — 위와 같은 값 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--color-canvas: #16121f;
|
||||
--color-paper: #1e1930;
|
||||
--color-mist: #322a45;
|
||||
--color-smoke: #4a4160;
|
||||
--color-ash: #8b86a0;
|
||||
--color-slate: #b3aec6;
|
||||
--color-charcoal: #e4e0f0;
|
||||
--color-carbon: #f0edf8;
|
||||
--color-canvas: #1b1e23;
|
||||
--color-paper: #121417;
|
||||
--color-mist: #2c3038;
|
||||
--color-smoke: #4a4f58;
|
||||
--color-ash: #7d848e;
|
||||
--color-slate: #9aa1ab;
|
||||
--color-charcoal: #e6e8eb;
|
||||
--color-carbon: #f0f1f3;
|
||||
--color-ink: #ffffff;
|
||||
|
||||
--color-deep-iris: #d8ccff;
|
||||
--color-plum-velvet: #c4b8e6;
|
||||
--color-royal-amethyst: #b99aff;
|
||||
--color-mist-violet: #2a2140;
|
||||
|
||||
--color-bg: var(--color-canvas);
|
||||
--color-surface: var(--color-paper);
|
||||
--color-surface-raised: #251f38;
|
||||
--color-border: var(--color-mist);
|
||||
--color-text: var(--color-deep-iris);
|
||||
--color-text-body: var(--color-charcoal);
|
||||
--color-text-secondary: var(--color-slate);
|
||||
--color-text-muted: var(--color-ash);
|
||||
--color-primary: var(--color-royal-amethyst);
|
||||
--color-primary-text: #16121f;
|
||||
--color-accent: var(--color-lavender-wash);
|
||||
--color-focus-ring: var(--color-lavender-wash);
|
||||
--color-accent: #3fae75;
|
||||
--color-accent-soft: #173326;
|
||||
--color-hover: #242830;
|
||||
--color-border-strong: #3b414b;
|
||||
--color-primary-text: #0e1512;
|
||||
--color-primary-hover: color-mix(in srgb, var(--color-primary) 85%, #fff);
|
||||
|
||||
--color-success: #3fbb6f;
|
||||
--color-warning: #f5a524;
|
||||
--color-danger: #e8695a;
|
||||
--panel-handle-border: #e0b64a;
|
||||
--color-danger-fill: #f06b6f;
|
||||
--color-info: #6aa6e0;
|
||||
--color-scrim: rgba(0, 0, 0, 0.55);
|
||||
--map-route-locked: #facc15;
|
||||
--map-route-node: #4ade80;
|
||||
|
||||
--shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px;
|
||||
--shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user