From 59a4dd4ebacc4f10b641367c2f8938c0e327d579 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 17:31:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(B01):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=9A=8C=EC=82=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EA=B3=A0=EB=A5=B8=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EC=B9=A8(29-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시스템 관리자 화면: 「시스템 설정」을 리소스 현황 아래로 · 제목 줄 오른쪽에 회사 고르기(기본 자기 회사) - 고른 회사 = sessionStorage aislo.adminCompany · 프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리 · 팀원 추가가 그 회사 것 - 카드 해시에 &company= · 회사 관리자 · 개인 화면은 그대로 · 임시 보관함은 사람 것이라 그대로 - 서버: /admin/projects · /admin/join-requests · /admin/members(POST · invite)에 company_id(_scope_company 한 곳 · 시스템 관리자만 남의 회사) · /admin/users 에 회사 거르기 - 라우터 700줄 넘어 나눔: 권한 도우미 B01_Dashboard_Scope · 회사 자산 B01_Dashboard_Router_Assets(본체가 include) - 시험 test_b01_settings_cards 보탬 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 34 +++- B01_Dashboard/B01_Dashboard_Repository.py | 8 +- B01_Dashboard/B01_Dashboard_Router.py | 196 +++---------------- B01_Dashboard/B01_Dashboard_Router_Assets.py | 129 ++++++++++++ B01_Dashboard/B01_Dashboard_Scope.py | 36 ++++ B01_Dashboard/B01_Dashboard_UI_Modals.ts | 9 +- B01_Dashboard/B01_Dashboard_UI_Page.ts | 87 ++++++-- B01_Dashboard/B01_Dashboard_UI_Style.css | 18 ++ resources/tester/test_b01_settings_cards.py | 111 +++++++++-- ui_template/ui_template_locale_b3.ts | 1 + 10 files changed, 409 insertions(+), 220 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_Router_Assets.py create mode 100644 B01_Dashboard/B01_Dashboard_Scope.py diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index cd122ca1..a4d41a74 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -360,16 +360,23 @@ export async function searchMemberCandidates(query: string): Promise { } /** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */ -export function addCompanyMember(userId: number): Promise<{ member: Member }> { - return request("/dashboard/admin/members", { +export function addCompanyMember( + userId: number, + companyId?: number | null, +): Promise<{ member: Member }> { + return request(`/dashboard/admin/members${companyQuery(companyId)}`, { method: "POST", body: body({ user_id: userId }), }); } /** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */ -export function inviteMember(email: string, name?: string | null): Promise { - return request("/dashboard/admin/members/invite", { +export function inviteMember( + email: string, + name?: string | null, + companyId?: number | null, +): Promise { + return request(`/dashboard/admin/members/invite${companyQuery(companyId)}`, { method: "POST", body: body({ email, name: name || null }), }); @@ -395,8 +402,11 @@ export function removeCompanyMember(userId: number): Promise { return request(`/dashboard/admin/members/${userId}`, { method: "DELETE" }); } -export async function fetchCompanyJoinRequests(): Promise { - const data = await request<{ requests: JoinRequest[] }>("/dashboard/admin/join-requests"); +/** companyId 는 시스템관리자가 대시보드에서 고른 회사(29-1) — 없으면 자기 회사. */ +export async function fetchCompanyJoinRequests(companyId?: number | null): Promise { + const data = await request<{ requests: JoinRequest[] }>( + `/dashboard/admin/join-requests${companyQuery(companyId)}`, + ); return data.requests; } @@ -410,8 +420,10 @@ export function processJoinRequest( }); } -export async function fetchCompanyProjects(): Promise { - const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects"); +export async function fetchCompanyProjects(companyId?: number | null): Promise { + const data = await request<{ projects: ProjectItem[] }>( + `/dashboard/admin/projects${companyQuery(companyId)}`, + ); return data.projects; } @@ -425,8 +437,10 @@ export async function fetchAllCompanies(): Promise { return data.companies; } -export async function fetchAllUsers(): Promise { - const data = await request<{ users: DashboardUser[] }>("/dashboard/admin/users"); +export async function fetchAllUsers(companyId?: number | null): Promise { + const data = await request<{ users: DashboardUser[] }>( + `/dashboard/admin/users${companyQuery(companyId)}`, + ); return data.users; } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index b7fe4ac8..658c042f 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -338,15 +338,17 @@ async def soft_delete_project(project_id: str, actor_id: int, request: Any | Non return changed -async def list_all_users() -> list[dict[str, Any]]: +async def list_all_users(company_id: int | None = None) -> list[dict[str, Any]]: + where = " AND u.company_id = %s" if company_id else "" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """SELECT u.id, u.email, u.name, u.position, u.department, u.phone, + f"""SELECT u.id, u.email, u.name, u.position, u.department, u.phone, u.role, u.status, u.company_id, c.name AS company_name, u.last_login FROM users u LEFT JOIN companies c ON c.id = u.company_id - WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC LIMIT 200""" + WHERE u.deleted_at IS NULL{where} ORDER BY u.created_at DESC LIMIT 200""", + (company_id,) if company_id else (), ) rows = list(await cursor.fetchall()) for row in rows: diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 9854c4be..9de54c5e 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -1,28 +1,14 @@ """B01_Dashboard 역할별 대시보드 API.""" import html -import mimetypes -import os from typing import Any -from fastapi import ( - APIRouter, - Depends, - File, - Form, - HTTPException, - Path, - Query, - Request, - Response, - UploadFile, -) +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response from pymysql.err import IntegrityError from common_util.common_util_auth import require_company, require_system_admin, verify_session from common_util.common_util_email import send_email from common_util.common_util_project_delete import hard_delete_project -from common_util.common_util_storage import read_stored_asset from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address @@ -47,17 +33,6 @@ from .B01_Dashboard_Repository import ( update_project, update_user_profile, ) -from .B01_Dashboard_Repository_Assets import ( - ASSET_EXTENSIONS, - ASSET_KINDS, - ASSET_MAX_BYTES, - create_company_asset, - delete_company_asset, - get_company_asset, - list_company_assets, - update_company_asset, - write_company_asset_file, -) from .B01_Dashboard_Repository_Company import ( create_company, get_user_company, @@ -75,9 +50,9 @@ from .B01_Dashboard_Repository_Members import ( list_company_members, list_unassigned_users, remove_company_member, - set_company_logo, set_project_members, ) +from .B01_Dashboard_Router_Assets import router as assets_router from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, @@ -88,52 +63,19 @@ from .B01_Dashboard_Schema import ( JoinCompanyRequest, ProcessJoinRequest, UiPrefsRequest, - UpdateCompanyAssetRequest, - UpdateCompanyLogoRequest, UpdateProjectRequest, UpdateUserRequest, ) +from .B01_Dashboard_Scope import ( + _require_company_id, + _same_company, + _scope_company, + require_company_admin, +) router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"]) -async def require_company_admin( - session: dict[str, Any] = Depends(verify_session), -) -> dict[str, Any]: - if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]: - raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.") - return session - - -def _require_company_id(session: dict[str, Any]) -> int: - company_id = session.get("company_id") - if company_id is None: - raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") - return int(company_id) - - -def _same_company(session: dict[str, Any], company_id: int | None) -> bool: - return company_id is not None and int(session.get("company_id") or 0) == int(company_id) - - -def _scope_company(session: dict[str, Any], company_id: int | None) -> int: - """회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다.""" - if session["role"] == "SYSTEM_ADMIN" and company_id is not None: - return int(company_id) - own = _require_company_id(session) - if company_id is not None and int(company_id) != own: - raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.") - return own - - -async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]: - asset = await get_company_asset(asset_id) - if not asset: - raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.") - _scope_company(session, int(asset["company_id"])) - return asset - - async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True @@ -326,9 +268,10 @@ async def admin_member_candidates( @router.post("/admin/members") async def admin_add_member( payload: AddMemberRequest, + company_id: int | None = Query(None, gt=0), session: dict[str, Any] = Depends(require_company_admin), ): - member = await attach_company_member(_require_company_id(session), payload.user_id) + member = await attach_company_member(_scope_company(session, company_id), payload.user_id) if not member: raise HTTPException( status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다." @@ -339,10 +282,11 @@ async def admin_add_member( @router.post("/admin/members/invite") async def admin_invite_member( payload: InviteMemberRequest, + company_id: int | None = Query(None, gt=0), session: dict[str, Any] = Depends(require_company_admin), ): """아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다.""" - company = await get_user_company(session.get("company_id")) + company = await get_user_company(_scope_company(session, company_id)) company_name = (company or {}).get("name") or "회사" # 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다. safe_company = html.escape(company_name) @@ -369,8 +313,12 @@ async def admin_remove_member( @router.get("/admin/join-requests") -async def admin_join_requests(session: dict[str, Any] = Depends(require_company_admin)): - return {"status": "success", "requests": await list_join_requests(_require_company_id(session))} +async def admin_join_requests( + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + scoped = _scope_company(session, company_id) + return {"status": "success", "requests": await list_join_requests(scoped)} @router.patch("/admin/join-requests/{request_id}") @@ -392,10 +340,13 @@ async def admin_process_join_request( @router.get("/admin/projects") -async def admin_projects(session: dict[str, Any] = Depends(require_company_admin)): +async def admin_projects( + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): return { "status": "success", - "projects": await list_company_projects(_require_company_id(session)), + "projects": await list_company_projects(_scope_company(session, company_id)), } @@ -470,9 +421,13 @@ async def system_create_company( @router.get("/admin/users") -async def system_users(session: dict[str, Any] = Depends(require_system_admin)): +async def system_users( + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_system_admin), +): + """company_id = 대시보드에서 고른 회사 사람만(29-1) · 없으면 전체.""" _ = session - return {"status": "success", "users": await list_all_users()} + return {"status": "success", "users": await list_all_users(company_id)} @router.patch("/admin/users/{user_id}/role") @@ -607,95 +562,4 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin return {"status": "success", "projects": await list_all_projects()} -# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ---- - - -@router.get("/company/assets") -async def company_assets( - company_id: int | None = Query(None, gt=0), - session: dict[str, Any] = Depends(verify_session), -): - return { - "status": "success", - "assets": await list_company_assets(_scope_company(session, company_id)), - } - - -@router.post("/company/assets") -async def company_add_asset( - kind: str = Form(...), - label: str = Form(...), - user_id: int | None = Form(None, gt=0), - company_id: int | None = Form(None, gt=0), - file: UploadFile = File(...), - session: dict[str, Any] = Depends(verify_session), -): - kind = kind.upper() - if kind not in ASSET_KINDS: - raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.") - label = label.strip()[:100] - if not label: - raise HTTPException(status_code=400, detail="이름을 넣어 주세요.") - suffix = os.path.splitext(file.filename or "")[1].lower() - if suffix not in ASSET_EXTENSIONS: - raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.") - blob = await file.read() - if not blob or len(blob) > ASSET_MAX_BYTES: - raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.") - scoped = _scope_company(session, company_id) - if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}: - raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.") - path = write_company_asset_file(scoped, kind, suffix, blob) - asset_id = await create_company_asset( - scoped, kind, label, path, user_id, int(session["user_id"]) - ) - return {"status": "success", "asset_id": asset_id} - - -@router.put("/company/assets/{asset_id}") -async def company_update_asset( - asset_id: int, - payload: UpdateCompanyAssetRequest, - session: dict[str, Any] = Depends(verify_session), -): - await _company_asset(session, asset_id) - await update_company_asset(asset_id, payload.label.strip(), payload.user_id) - return {"status": "success"} - - -@router.delete("/company/assets/{asset_id}") -async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)): - await _company_asset(session, asset_id) - await delete_company_asset(asset_id) - return {"status": "success"} - - -@router.put("/company/logo") -async def company_set_logo( - payload: UpdateCompanyLogoRequest, - company_id: int | None = Query(None, gt=0), - session: dict[str, Any] = Depends(require_company_admin), -): - """회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정). - - 프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다. - """ - scoped = _scope_company(session, company_id) - if payload.logo_asset_id is not None: - asset = await get_company_asset(payload.logo_asset_id) - if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO": - raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.") - if not await set_company_logo(scoped, payload.logo_asset_id): - raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") - return {"status": "success"} - - -@router.get("/company/assets/{asset_id}/file") -async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): - """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" - asset = await _company_asset(session, asset_id) - blob = read_stored_asset(asset["file_path"]) - if blob is None: - raise HTTPException(status_code=404, detail="그림 파일이 없습니다.") - media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream" - return Response(content=blob, media_type=media_type) +router.include_router(assets_router) diff --git a/B01_Dashboard/B01_Dashboard_Router_Assets.py b/B01_Dashboard/B01_Dashboard_Router_Assets.py new file mode 100644 index 00000000..27077ffb --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Router_Assets.py @@ -0,0 +1,129 @@ +"""B01_Dashboard 회사 공유 도면 자산(로고·서명) API — 본체 라우터가 include 함.""" + +import mimetypes +import os +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile + +from common_util.common_util_auth import verify_session +from common_util.common_util_storage import read_stored_asset + +from .B01_Dashboard_Repository_Assets import ( + ASSET_EXTENSIONS, + ASSET_KINDS, + ASSET_MAX_BYTES, + create_company_asset, + delete_company_asset, + get_company_asset, + list_company_assets, + update_company_asset, + write_company_asset_file, +) +from .B01_Dashboard_Repository_Members import list_company_members, set_company_logo +from .B01_Dashboard_Schema import UpdateCompanyAssetRequest, UpdateCompanyLogoRequest +from .B01_Dashboard_Scope import _scope_company, require_company_admin + +router = APIRouter() + + +async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]: + asset = await get_company_asset(asset_id) + if not asset: + raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.") + _scope_company(session, int(asset["company_id"])) + return asset + + +# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ---- + + +@router.get("/company/assets") +async def company_assets( + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(verify_session), +): + return { + "status": "success", + "assets": await list_company_assets(_scope_company(session, company_id)), + } + + +@router.post("/company/assets") +async def company_add_asset( + kind: str = Form(...), + label: str = Form(...), + user_id: int | None = Form(None, gt=0), + company_id: int | None = Form(None, gt=0), + file: UploadFile = File(...), + session: dict[str, Any] = Depends(verify_session), +): + kind = kind.upper() + if kind not in ASSET_KINDS: + raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.") + label = label.strip()[:100] + if not label: + raise HTTPException(status_code=400, detail="이름을 넣어 주세요.") + suffix = os.path.splitext(file.filename or "")[1].lower() + if suffix not in ASSET_EXTENSIONS: + raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.") + blob = await file.read() + if not blob or len(blob) > ASSET_MAX_BYTES: + raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.") + scoped = _scope_company(session, company_id) + if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}: + raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.") + path = write_company_asset_file(scoped, kind, suffix, blob) + asset_id = await create_company_asset( + scoped, kind, label, path, user_id, int(session["user_id"]) + ) + return {"status": "success", "asset_id": asset_id} + + +@router.put("/company/assets/{asset_id}") +async def company_update_asset( + asset_id: int, + payload: UpdateCompanyAssetRequest, + session: dict[str, Any] = Depends(verify_session), +): + await _company_asset(session, asset_id) + await update_company_asset(asset_id, payload.label.strip(), payload.user_id) + return {"status": "success"} + + +@router.delete("/company/assets/{asset_id}") +async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)): + await _company_asset(session, asset_id) + await delete_company_asset(asset_id) + return {"status": "success"} + + +@router.put("/company/logo") +async def company_set_logo( + payload: UpdateCompanyLogoRequest, + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + """회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정). + + 프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다. + """ + scoped = _scope_company(session, company_id) + if payload.logo_asset_id is not None: + asset = await get_company_asset(payload.logo_asset_id) + if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO": + raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.") + if not await set_company_logo(scoped, payload.logo_asset_id): + raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/company/assets/{asset_id}/file") +async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): + """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" + asset = await _company_asset(session, asset_id) + blob = read_stored_asset(asset["file_path"]) + if blob is None: + raise HTTPException(status_code=404, detail="그림 파일이 없습니다.") + media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream" + return Response(content=blob, media_type=media_type) diff --git a/B01_Dashboard/B01_Dashboard_Scope.py b/B01_Dashboard/B01_Dashboard_Scope.py new file mode 100644 index 00000000..fb0d9b5b --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Scope.py @@ -0,0 +1,36 @@ +"""B01_Dashboard 권한 · 회사 범위 도우미 — 라우터 둘(본체 · 자산)이 함께 씀.""" + +from typing import Any + +from fastapi import Depends, HTTPException + +from common_util.common_util_auth import verify_session + + +async def require_company_admin( + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + if session["role"] not in ("ADMIN", "SYSTEM_ADMIN") and not session["is_master"]: + raise HTTPException(status_code=403, detail="회사 관리자 권한이 필요합니다.") + return session + + +def _require_company_id(session: dict[str, Any]) -> int: + company_id = session.get("company_id") + if company_id is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + return int(company_id) + + +def _same_company(session: dict[str, Any], company_id: int | None) -> bool: + return company_id is not None and int(session.get("company_id") or 0) == int(company_id) + + +def _scope_company(session: dict[str, Any], company_id: int | None) -> int: + """회사 범위 자원의 회사 — 시스템관리자면 인자 우선(대시보드 회사 고르기) · 남은 세션 회사.""" + if session["role"] == "SYSTEM_ADMIN" and company_id is not None: + return int(company_id) + own = _require_company_id(session) + if company_id is not None and int(company_id) != own: + raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.") + return own diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index c54e6b4e..6a776638 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -584,7 +584,10 @@ export function openFindCompanyModal(): void { * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 * 안내 메일만 보낸다. */ -export function openAddMemberModal(onCreated?: (member: Member) => void): void { +export function openAddMemberModal( + onCreated?: (member: Member) => void, + companyId?: number | null, +): void { const query = createInputField({ label: "이름 또는 이메일로 찾기", placeholder: "두 글자 이상", @@ -638,7 +641,7 @@ export function openAddMemberModal(onCreated?: (member: Member) => void): void { return; } query.setError(); - await inviteMember(text); + await inviteMember(text, null, companyId); showToast("안내 메일을 보냈습니다.", "success"); }, }); @@ -652,7 +655,7 @@ export function openAddMemberModal(onCreated?: (member: Member) => void): void { query.setError("등록할 사람을 먼저 고르십시오."); throw new Error("등록할 사람을 먼저 고르십시오."); } - const created = await addCompanyMember(picked.id); + const created = await addCompanyMember(picked.id, companyId); showToast(L("B01_Dashboard_Saved"), "success"); if (created?.member) onCreated?.(created.member); }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index bf73c045..243720e8 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -2,6 +2,7 @@ import { ROUTES } from "@config/config_frontend"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, + createSelectField, createTag, hideLoadingOverlay, showLoadingOverlay, @@ -12,8 +13,6 @@ import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, - fetchAllJoinRequests, - fetchAllProjects, fetchAllUsers, fetchAuditLogs, fetchCompanyJoinRequests, @@ -59,6 +58,18 @@ export interface DashboardState { allJoinRequests: JoinRequest[]; auditLogs: AuditLog[]; resources: ResourceData | null; + /** 시스템 관리자가 고른 회사(29-1) — 프로젝트 · 사용자 · 가입 요청 · 회사 관리가 이 회사 것 */ + adminCompanyId: number | null; +} + +/** 고른 회사 id 자리 — M02 도 같은 키를 읽음(이름 바꾸지 않음) */ +const ADMIN_COMPANY_KEY = "aislo.adminCompany"; + +/** 고른 회사 — 저장값이 없거나 없는 회사면 자기 회사 */ +function pickAdminCompany(user: DashboardUser, companies: CompanyInfo[]): number | null { + const saved = Number(sessionStorage.getItem(ADMIN_COMPANY_KEY)); + if (companies.some((c) => c.id === saved)) return saved; + return user.company_id ?? companies[0]?.id ?? null; } export async function renderB01Dashboard(root: HTMLElement): Promise { @@ -78,6 +89,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { allJoinRequests: [], auditLogs: [], resources: null, + adminCompanyId: null, }; await loadRoleData(state); root.innerHTML = ""; @@ -91,20 +103,22 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { async function loadRoleData(state: DashboardState): Promise { if (state.user.role === "SYSTEM_ADMIN") { + // 회사 목록을 먼저 — 고른 회사를 가린 뒤 그 회사 것만 읽음(29-1) + state.allCompanies = await fetchAllCompanies().catch(() => []); + const companyId = pickAdminCompany(state.user, state.allCompanies); + state.adminCompanyId = companyId; const results = await Promise.allSettled([ - fetchAllCompanies(), - fetchAllUsers(), - fetchAllJoinRequests(), + fetchAllUsers(companyId), + fetchCompanyJoinRequests(companyId), fetchAuditLogs(), fetchSystemResources(7), - fetchAllProjects(), + fetchCompanyProjects(companyId), ]); - state.allCompanies = results[0].status === "fulfilled" ? results[0].value : []; - state.allUsers = results[1].status === "fulfilled" ? results[1].value : []; - state.allJoinRequests = results[2].status === "fulfilled" ? results[2].value : []; - state.auditLogs = results[3].status === "fulfilled" ? results[3].value : []; - state.resources = results[4].status === "fulfilled" ? results[4].value : null; - state.allProjects = results[5].status === "fulfilled" ? results[5].value : []; + 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") { @@ -148,11 +162,16 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement { } /** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ -function buildSystemSettingsPanel(user: DashboardUser): HTMLElement { +function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__setting-grid"; + // 시스템 관리자는 고른 회사를 해시에 실음(29-1) + const company: Record = + user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => - settingCard(title, desc, () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root })); + settingCard(title, desc, () => + navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }), + ); if (user.role === "SYSTEM_ADMIN") { const desc = L("B01_Dashboard_PermMasterWrite"); wrap.append( @@ -181,9 +200,30 @@ function buildSystemSettingsPanel(user: DashboardUser): HTMLElement { return wrap; } +/** 회사 고르기(시스템 관리자만) — 고르면 저장 뒤 대시보드를 다시 읽음 */ +function buildCompanyPicker(state: DashboardState): HTMLElement { + const field = createSelectField({ + label: L("B01_Dashboard_PickCompany"), + options: state.allCompanies.map((c) => ({ value: String(c.id), text: c.name })), + value: String(state.adminCompanyId ?? ""), + compact: true, + onChange: (value) => { + sessionStorage.setItem(ADMIN_COMPANY_KEY, value); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }, + }); + field.root.classList.add("b01-dashboard__company-pick"); + // 제목 줄 누름 = 접힘 — 고르기 누름은 올려 보내지 않음 + field.root.addEventListener("click", (event) => event.stopPropagation()); + return field.root; +} + /** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */ -function buildSystemSettingsSection(user: DashboardUser): HTMLElement { - const card = section(L("B01_Dashboard_SystemSettings"), buildSystemSettingsPanel(user), true); +function buildSystemSettingsSection(state: DashboardState): HTMLElement { + const { user } = state; + const picker = user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []; + const panel = buildSystemSettingsPanel(user, state.adminCompanyId); + const card = section(L("B01_Dashboard_SystemSettings"), panel, true, picker); card.querySelector(".ui-card__body")?.classList.add("ui-collapsible"); card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title"); attachCollapsible(card); @@ -196,26 +236,32 @@ function buildPage(state: DashboardState): HTMLElement { const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; - // 시스템 설정은 모든 역할 맨 위 한 줄(27-3a) - grid.append(buildSystemSettingsSection(state.user)); if (state.user.role === "SYSTEM_ADMIN") { + // 시스템 설정은 리소스 현황 아래(29-1) · 그 아래 컨테이너는 고른 회사 것 + const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId); grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), + buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), buildTempUploadSection(), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ - createButton({ label: "+", onClick: () => openAddMemberModal() }), + createButton({ + label: "+", + onClick: () => openAddMemberModal(undefined, state.adminCompanyId), + }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), - section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [ + section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), ); } else if (state.user.role === "ADMIN") { + // 시스템 설정은 맨 위 한 줄(27-3a) grid.append( + buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.companyProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), @@ -228,6 +274,7 @@ function buildPage(state: DashboardState): HTMLElement { ); } else { grid.append( + buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.userProjects, state.user), true, [ createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 87d02e70..0e082f38 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -68,6 +68,24 @@ font-size: var(--text-caption); } +/* 회사 고르기 — 시스템 설정 제목 줄 오른쪽 · 라벨과 고르기를 한 줄로 */ +.ui-field.b01-dashboard__company-pick { + flex-direction: row; + align-items: center; + gap: var(--spacing-8); + margin: 0; +} + +/* 접히는 제목 줄 — 단추 칸을 접힘 표시 바로 왼쪽으로 */ +.ui-collapsible__title > .b01-dashboard__actions { + margin-left: auto; +} + +.b01-dashboard__company-pick .ui-field__label { + margin: 0; + white-space: nowrap; +} + .b01-dashboard__actions { display: flex; flex-wrap: wrap; diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 8f80b046..b36822cc 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -1,7 +1,8 @@ """B01 대시보드 「시스템 설정」 컨테이너 시험(27-3) — 화면 조작은 ORCA. · 역할별 카드(제목 · 설명 · 이동 해시)를 Node 로 바로 돌림 — 가짜 DOM -· 모든 역할 맨 위 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음 +· 모든 역할 한 줄 · 통째로 접힘 · 「구조물 관리」 컨테이너 없음 +· 회사 고르기(29-1) — 시스템 관리자만 · 리소스 현황 아래 · 해시 company · 서버 인자 한 곳 """ from __future__ import annotations @@ -28,16 +29,18 @@ def _fn(src: str, name: str) -> str: return found.group(0) -def _cards(user: dict) -> list: +def _cards(user: dict, company_id: int | None = None) -> list: src = _text(PAGE) script = ( "interface DashboardUser { role: string; is_master: boolean }\n" - "const ROUTES = { M01_MASTER_DATA: 'm01-master-data', M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," + " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" "const L = (k: string) => k;\n" "let went = '';\n" "const navigateTo = (r: string, q?: Record) =>" " { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n" - "const mk = (): any => ({ children: [] as any[], textContent: '', className: '', on: null as any," + "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" @@ -45,13 +48,14 @@ def _cards(user: dict) -> list: + "\n" + _fn(src, "buildSystemSettingsPanel") + "\nconst user = JSON.parse(process.argv[1]);" - + "\nconsole.log(JSON.stringify(buildSystemSettingsPanel(user).children.map((c: any) => {" + + "\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)]; })));" ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", - "--input-type=module-typescript", "-e", script, json.dumps(user)], + "--input-type=module-typescript", "-e", script, json.dumps(user), json.dumps(company_id)], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr @@ -66,15 +70,19 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_카드_다섯(): +def test_시스템_관리자_카드_다섯_고른_회사_해시(): m = "B01_Dashboard_PermMasterWrite" - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}) == [ + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], - ["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master"], - ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master"], - ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master"], - ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master"], + ["B01_Dashboard_CardUnitCombo", m, f"{M02}?open=일위대가&root=master&company=3"], + ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"], + ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], + ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], ] + # 회사가 없으면 해시에 안 실음 + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == ( + f"{M02}?open=일위대가&root=master" + ) @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") @@ -83,7 +91,7 @@ def test_회사_관리자_개인_카드_둘(): ["B01_Dashboard_CardUnitCombo", WRITE_CO, f"{M02}?open=일위대가&root=company"], ["B01_Dashboard_CardStructCombo", WRITE_CO, f"{M02}?open=구조물조합&root=company"], ] - assert _cards({"role": "ADMIN", "is_master": False}) == company + assert _cards({"role": "ADMIN", "is_master": False}, 3) == company # 회사 인자 안 실음 assert _cards({"role": "USER", "is_master": True}) == company # 회사 마스터 = 회사 수정 assert _cards({"role": "USER", "is_master": False}) == [ ["B01_Dashboard_CardUnitCombo", READ_CO, f"{M02}?open=일위대가&root=personal"], @@ -91,18 +99,85 @@ def test_회사_관리자_개인_카드_둘(): ] -def test_모든_역할_맨_위_통째로_접힘(): +def test_모든_역할_한_줄_통째로_접힘(): src = _text(PAGE) build = _fn(src, "buildPage") - # 역할 갈래 앞에서 한 번 — 모든 역할 · 프로젝트 위 - assert build.index("grid.append(buildSystemSettingsSection(state.user))") < build.index( - 'if (state.user.role === "SYSTEM_ADMIN")' + # 세 갈래 모두 한 번씩 · 시스템 관리자는 리소스 현황 아래(29-1) · 프로젝트 위 + assert build.count("buildSystemSettingsSection(state)") == 3 + sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] + assert ( + sys_part.index('L("B01_Dashboard_Resources")') + < sys_part.index("buildSystemSettingsSection(state)") + < sys_part.index('L("B01_Dashboard_Projects")') ) assert "StructureManage" not in src and "buildStructurePanel" not in src assert "top-row" not in src sect = _fn(src, "buildSystemSettingsSection") - assert "buildSystemSettingsPanel(user), true)" in sect # 한 줄 통째 + assert 'L("B01_Dashboard_SystemSettings"), panel, true, picker)' in sect # 한 줄 통째 assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect assert "attachCollapsible(card)" in sect config = _text(ROOT / "config" / "config_frontend.ts") assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config) + + +def test_회사_고르기_시스템_관리자만(): + src = _text(PAGE) + sect = _fn(src, "buildSystemSettingsSection") + assert 'user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []' in sect + picker = _fn(src, "buildCompanyPicker") + assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, value)" in picker + assert "event.stopPropagation()" in picker # 고르기 누름이 접힘으로 새지 않음 + assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 + load = src.split("async function loadRoleData")[1].split("\n}\n")[0] + for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)", + "fetchCompanyProjects(companyId)"): # fmt: skip + assert call in load, call + build = _fn(src, "buildPage") + assert "companyTable(picked, state.user)" in build + assert "openAddMemberModal(undefined, state.adminCompanyId)" in build + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_고른_회사_저장값_검사(): + src = _text(PAGE) + script = ( + "interface DashboardUser { company_id?: number | null }\n" + "interface CompanyInfo { id: number }\n" + "const ADMIN_COMPANY_KEY = 'aislo.adminCompany';\n" + "const store: Record = {};\n" + "const sessionStorage = { getItem: (k: string) => store[k] ?? null };\n" + + _fn(src, "pickAdminCompany") + + "\nconst cs = [{ id: 1 }, { id: 3 }];" + + "\nconst out = [pickAdminCompany({ company_id: 1 }, cs)];" + + "\nstore[ADMIN_COMPANY_KEY] = '3'; out.push(pickAdminCompany({ company_id: 1 }, cs));" + + "\nstore[ADMIN_COMPANY_KEY] = '9'; out.push(pickAdminCompany({ company_id: 1 }, cs));" + + "\nout.push(pickAdminCompany({ company_id: null }, cs));" + + "\nconsole.log(JSON.stringify(out));" + ) + 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 + # 기본 자기 회사 · 고른 회사 · 없는 회사면 자기 회사 · 자기 회사 없으면 첫 회사 + assert json.loads(result.stdout) == [1, 3, 1, 1] + + +def test_서버_회사_인자_한_곳(): + scope = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_Scope.py") + assert 'if session["role"] == "SYSTEM_ADMIN" and company_id is not None:' in scope + router = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_Router.py") + for name in ( + "admin_add_member", + "admin_invite_member", + "admin_join_requests", + "admin_projects", + ): + body = router.split(f"async def {name}(")[1].split("@router.")[0] + assert "company_id: int | None = Query(None, gt=0)" in body, name + assert "_scope_company(session, company_id)" in body, name + users = router.split("async def system_users(")[1].split("@router.")[0] + assert "require_system_admin" in users and "list_all_users(company_id)" in users + assert "router.include_router(assets_router)" in router + assert len(router.splitlines()) <= 700 diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 24684799..63b1d25d 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -145,6 +145,7 @@ export const ui_locales_b3 = { B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"], B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"], B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"], + B01_Dashboard_PickCompany: ["회사", "Company"], /* --- B08·B09 빈 화면 (PLAN 7-3) --- */ B08_Quantity_Rebuilding: [ From e7d8ea80020d0d0246596edfba44fba4b1194309 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 17:35:59 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B01):=20=ED=9A=8C=EC=82=AC=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=EB=A5=BC=20=EC=95=8C=EC=95=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=ED=9A=8C=EC=82=AC=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=20=EB=B0=94=EB=80=8C=EB=8A=94=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A7=88=EB=8B=A4(29-1=20=EA=B3=A0=EC=B9=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고르기 select 뺌 · 「시스템 설정」 · 프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리 제목 오른쪽에 고른 회사 알약(공용 ui-tag 모양) - 누르면 등록된 회사 작은 메뉴(공용 createMapContextMenu) · 어디서 골라도 함께 바뀜 · 알약 누름은 접힘으로 안 샘 - 회사 관리자 · 개인 화면은 알약 없음 · 임시 보관함 그대로 - 시험 test_b01_settings_cards 알약 판정으로 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- B01_Dashboard/B01_Dashboard_UI_Page.ts | 64 ++++++++++++++------- B01_Dashboard/B01_Dashboard_UI_Style.css | 47 +++++++++++++-- resources/tester/test_b01_settings_cards.py | 18 ++++-- ui_template/ui_template_locale_b3.ts | 2 +- 4 files changed, 98 insertions(+), 33 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 243720e8..3d0c9971 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -2,12 +2,12 @@ import { ROUTES } from "@config/config_frontend"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, - createSelectField, createTag, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createMapContextMenu, type MapContextMenu } from "@ui/ui_template_context_menu"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; @@ -200,30 +200,46 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null) return wrap; } -/** 회사 고르기(시스템 관리자만) — 고르면 저장 뒤 대시보드를 다시 읽음 */ -function buildCompanyPicker(state: DashboardState): HTMLElement { - const field = createSelectField({ - label: L("B01_Dashboard_PickCompany"), - options: state.allCompanies.map((c) => ({ value: String(c.id), text: c.name })), - value: String(state.adminCompanyId ?? ""), - compact: true, - onChange: (value) => { - sessionStorage.setItem(ADMIN_COMPANY_KEY, value); - window.dispatchEvent(new HashChangeEvent("hashchange")); - }, +/** 회사 메뉴 — 공용 작은 메뉴 하나를 알약 모두가 같이 씀 */ +let companyMenu: MapContextMenu | null = null; + +/** 회사 알약(시스템 관리자만) — 누르면 등록된 회사 메뉴 · 고르면 저장 뒤 대시보드를 다시 읽음 */ +function companyPill(state: DashboardState): HTMLElement { + const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-"; + const pill = document.createElement("button"); + pill.type = "button"; + pill.className = "ui-tag ui-tag--accent b01-dashboard__company-pill"; + pill.title = L("B01_Dashboard_PickCompany"); + pill.textContent = `${name} ▾`; + pill.addEventListener("click", (event) => { + // 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음 + event.stopPropagation(); + if (!companyMenu?.element.isConnected) { + companyMenu = createMapContextMenu("b01-dashboard"); + document.body.append(companyMenu.element); + } + const box = pill.getBoundingClientRect(); + companyMenu.open( + box.left, + box.bottom + 4, + state.allCompanies.map((c): [string, () => void] => [ + c.name, + () => { + sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id)); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }, + ]), + ); }); - field.root.classList.add("b01-dashboard__company-pick"); - // 제목 줄 누름 = 접힘 — 고르기 누름은 올려 보내지 않음 - field.root.addEventListener("click", (event) => event.stopPropagation()); - return field.root; + return pill; } /** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */ function buildSystemSettingsSection(state: DashboardState): HTMLElement { const { user } = state; - const picker = user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []; + const pill = user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []; const panel = buildSystemSettingsPanel(user, state.adminCompanyId); - const card = section(L("B01_Dashboard_SystemSettings"), panel, true, picker); + const card = section(L("B01_Dashboard_SystemSettings"), panel, true, pill); card.querySelector(".ui-card__body")?.classList.add("ui-collapsible"); card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title"); attachCollapsible(card); @@ -238,23 +254,31 @@ function buildPage(state: DashboardState): HTMLElement { grid.className = "b01-dashboard__grid"; if (state.user.role === "SYSTEM_ADMIN") { - // 시스템 설정은 리소스 현황 아래(29-1) · 그 아래 컨테이너는 고른 회사 것 + // 시스템 설정은 리소스 현황 아래(29-1) · 회사 알약이 붙은 컨테이너는 고른 회사 것 const picked = state.allCompanies.filter((c) => c.id === state.adminCompanyId); grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), buildSystemSettingsSection(state), section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }), ]), buildTempUploadSection(), section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => openAddMemberModal(undefined, state.adminCompanyId), }), ]), - section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), + section( + L("B01_Dashboard_JoinRequests"), + joinRequestTable(state.allJoinRequests, true), + true, + [companyPill(state)], + ), section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [ + companyPill(state), createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 0e082f38..f2c2a0a3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -68,12 +68,47 @@ font-size: var(--text-caption); } -/* 회사 고르기 — 시스템 설정 제목 줄 오른쪽 · 라벨과 고르기를 한 줄로 */ -.ui-field.b01-dashboard__company-pick { - flex-direction: row; - align-items: center; - gap: var(--spacing-8); - margin: 0; +/* 회사 알약 — 회사를 따라 바뀌는 컨테이너 제목 오른쪽 · 누르면 회사 메뉴 */ +.b01-dashboard__company-pill { + border: 0; + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.b01-dashboard__context-menu { + position: fixed; + z-index: var(--z-modal); + display: flex; + flex-direction: column; + min-width: 160px; + max-height: 60vh; + overflow-y: auto; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.b01-dashboard__context-menu[hidden] { + display: none; +} + +.b01-dashboard__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: inherit; + font: inherit; + font-size: var(--text-body-sm); + text-align: left; + cursor: pointer; +} + +.b01-dashboard__context-menu-item:hover { + background: var(--color-surface-sunken); } /* 접히는 제목 줄 — 단추 칸을 접힘 표시 바로 왼쪽으로 */ diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index b36822cc..f51ccef1 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -113,26 +113,32 @@ def test_모든_역할_한_줄_통째로_접힘(): assert "StructureManage" not in src and "buildStructurePanel" not in src assert "top-row" not in src sect = _fn(src, "buildSystemSettingsSection") - assert 'L("B01_Dashboard_SystemSettings"), panel, true, picker)' in sect # 한 줄 통째 + assert 'L("B01_Dashboard_SystemSettings"), panel, true, pill)' in sect # 한 줄 통째 assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect assert "attachCollapsible(card)" in sect config = _text(ROOT / "config" / "config_frontend.ts") assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config) -def test_회사_고르기_시스템_관리자만(): +def test_회사_알약_시스템_관리자만(): src = _text(PAGE) sect = _fn(src, "buildSystemSettingsSection") - assert 'user.role === "SYSTEM_ADMIN" ? [buildCompanyPicker(state)] : []' in sect - picker = _fn(src, "buildCompanyPicker") - assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, value)" in picker - assert "event.stopPropagation()" in picker # 고르기 누름이 접힘으로 새지 않음 + assert 'user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []' in sect + pill = _fn(src, "companyPill") + assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id))" in pill + assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음 + assert 'createMapContextMenu("b01-dashboard")' in pill # 공용 작은 메뉴 + assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양 assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키 load = src.split("async function loadRoleData")[1].split("\n}\n")[0] for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)", "fetchCompanyProjects(companyId)"): # fmt: skip assert call in load, call build = _fn(src, "buildPage") + # 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약 + sys_part = build.split('} else if (state.user.role === "ADMIN")')[0] + assert sys_part.count("companyPill(state)") == 4 + assert "companyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1] assert "companyTable(picked, state.user)" in build assert "openAddMemberModal(undefined, state.adminCompanyId)" in build diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 63b1d25d..48ad7110 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -145,7 +145,7 @@ export const ui_locales_b3 = { B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"], B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"], B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"], - B01_Dashboard_PickCompany: ["회사", "Company"], + B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"], /* --- B08·B09 빈 화면 (PLAN 7-3) --- */ B08_Quantity_Rebuilding: [