From 39bb1e6754659edc0ab1a06828cfa0ece915369b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 12:22:40 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B01):=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?700=EC=A4=84=20=EB=B6=84=EB=A6=AC=20+=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EC=A7=80=EC=A0=81=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B01_Dashboard_Repository 에서 회사·가입신청 부분을 B01_Dashboard_Repository_Company 로 분리 (740줄 -> 508줄) - 팀원 초대 메일 본문의 회사명·이름 HTML 이스케이프 (임의 HTML 삽입 차단) - 지도 타일 엔드포인트 z/x/y 범위 검증 추가 - 회사 정보 수정 시 회사명·사업자번호 중복은 409 로 응답 (기존 500) - 값 변경 없는 회사 수정이 404 로 튕기던 문제 수정 - 팀원 미선택 시 안내 문구 한글화 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Repository.py | 232 ---------------- .../B01_Dashboard_Repository_Company.py | 253 ++++++++++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 53 +++- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 2 +- 4 files changed, 293 insertions(+), 247 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_Repository_Company.py diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 25fee587..106f1fa6 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -324,238 +324,6 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool: return changed -async def get_user_company(company_id: int | None) -> dict[str, Any] | None: - if company_id is None: - return None - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT c.id, c.name, c.business_registration_number, c.business_address, - c.business_owner, c.business_status, c.logo_asset_id, - COUNT(DISTINCT u.id) AS user_count, - COUNT(DISTINCT p.id) AS project_count - FROM companies c - LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.id = %s AND c.deleted_at IS NULL - GROUP BY c.id""", - (company_id,), - ) - return await cursor.fetchone() - - -async def update_company(company_id: int, data: dict[str, Any]) -> bool: - """회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체.""" - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE companies - SET name = %s, business_registration_number = %s, - business_address = %s, business_owner = %s - WHERE id = %s AND deleted_at IS NULL""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - company_id, - ), - ) - changed = cursor.rowcount > 0 - await connection.commit() - return changed - - -async def search_companies(query: str) -> list[dict[str, Any]]: - pool = get_db_pool() - pattern = f"%{query}%" - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, name, business_registration_number, business_status - FROM companies - WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) - ORDER BY name LIMIT 20""", - (pattern, pattern), - ) - return list(await cursor.fetchall()) - - -async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, master_user_id, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - user_id, - user_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """UPDATE users - SET company_id = %s, role = 'ADMIN', is_master = TRUE, - status = 'ACTIVE' - WHERE id = %s""", - (company_id, user_id), - ) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (user_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - actor_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (actor_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - # 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) — - # 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다. - await cursor.execute( - "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE", - (user_id,), - ) - current = await cursor.fetchone() - if current and current[0]: - await connection.rollback() - return None - await cursor.execute( - """INSERT INTO join_requests (user_id, company_id, status) - VALUES (%s, %s, 'PENDING') - ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, - reviewed_by = NULL, reviewed_at = NULL""", - (user_id, company_id), - ) - request_id = cursor.lastrowid - await cursor.execute( - "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) - ) - await connection.commit() - return {"join_request_id": request_id, "status": "PENDING"} - except Exception: - await connection.rollback() - raise - - -async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: - # 처리 끝난 신청은 목록에 남기지 않는다 (2026-09-06 사용자 지시) — 승인된 사람은 - # 사용자 관리 목록에 이미 있어 같은 사람이 두 번 보였다. - where = "WHERE jr.status = 'PENDING'" + (" AND jr.company_id = %s" if company_id else "") - params = (company_id,) if company_id else () - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, - u.email AS user_email, u.name AS user_name, c.name AS company_name - FROM join_requests jr - JOIN users u ON u.id = jr.user_id - JOIN companies c ON c.id = jr.company_id - {where} - ORDER BY jr.requested_at DESC LIMIT 100""", - params, - ) - return list(await cursor.fetchall()) - - -async def process_join_request( - request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None -) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await connection.begin() - where = "id = %s AND status = 'PENDING'" - params: tuple[Any, ...] = (request_id,) - if company_id is not None: - where += " AND company_id = %s" - params = (request_id, company_id) - await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) - row = await cursor.fetchone() - if not row: - await connection.rollback() - return False - status = "APPROVED" if approved else "REJECTED" - await cursor.execute( - """UPDATE join_requests SET status = %s, reviewed_by = %s, - reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", - (status, reviewer_id, request_id), - ) - if approved: - # 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정). - role = await role_for_company(int(row["company_id"])) - await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s - WHERE id = %s""", - (row["company_id"], role, row["user_id"]), - ) - else: - await cursor.execute( - "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) - ) - await connection.commit() - return True - - -async def list_all_companies() -> list[dict[str, Any]]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT c.id, c.name, c.business_registration_number, c.business_status, - c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count, - COUNT(DISTINCT p.id) AS project_count - FROM companies c - LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" - ) - return list(await cursor.fetchall()) - - async def list_all_users() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py new file mode 100644 index 00000000..e7a4eb12 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -0,0 +1,253 @@ +"""회사·가입 신청 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한). + +회사를 만들고 고치고 찾는 일, 그리고 그 회사에 들어가겠다는 신청을 다루는 곳이다. +사용자·프로젝트 쪽은 `B01_Dashboard_Repository` 에 남는다. +""" + +from __future__ import annotations + +from typing import Any + +import aiomysql + +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import role_for_company + + +async def get_user_company(company_id: int | None) -> dict[str, Any] | None: + if company_id is None: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_address, + c.business_owner, c.business_status, c.logo_asset_id, + COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.id = %s AND c.deleted_at IS NULL + GROUP BY c.id""", + (company_id,), + ) + return await cursor.fetchone() + + +async def update_company(company_id: int, data: dict[str, Any]) -> bool: + """회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE companies + SET name = %s, business_registration_number = %s, + business_address = %s, business_owner = %s + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + company_id, + ), + ) + changed = cursor.rowcount > 0 + if not changed: + # 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 회사가 없는 것과는 다르다. + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (company_id,) + ) + changed = await cursor.fetchone() is not None + await connection.commit() + return changed + + +async def search_companies(query: str) -> list[dict[str, Any]]: + pool = get_db_pool() + pattern = f"%{query}%" + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, business_registration_number, business_status + FROM companies + WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) + ORDER BY name LIMIT 20""", + (pattern, pattern), + ) + return list(await cursor.fetchall()) + + +async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, master_user_id, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + user_id, + user_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """UPDATE users + SET company_id = %s, role = 'ADMIN', is_master = TRUE, + status = 'ACTIVE' + WHERE id = %s""", + (company_id, user_id), + ) + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (user_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + actor_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (actor_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + # 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) — + # 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다. + await cursor.execute( + "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE", + (user_id,), + ) + current = await cursor.fetchone() + if current and current[0]: + await connection.rollback() + return None + await cursor.execute( + """INSERT INTO join_requests (user_id, company_id, status) + VALUES (%s, %s, 'PENDING') + ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, + reviewed_by = NULL, reviewed_at = NULL""", + (user_id, company_id), + ) + request_id = cursor.lastrowid + await cursor.execute( + "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) + ) + await connection.commit() + return {"join_request_id": request_id, "status": "PENDING"} + except Exception: + await connection.rollback() + raise + + +async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: + # 처리 끝난 신청은 목록에 남기지 않는다 (2026-09-06 사용자 지시) — 승인된 사람은 + # 사용자 관리 목록에 이미 있어 같은 사람이 두 번 보였다. + where = "WHERE jr.status = 'PENDING'" + (" AND jr.company_id = %s" if company_id else "") + params = (company_id,) if company_id else () + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, + u.email AS user_email, u.name AS user_name, c.name AS company_name + FROM join_requests jr + JOIN users u ON u.id = jr.user_id + JOIN companies c ON c.id = jr.company_id + {where} + ORDER BY jr.requested_at DESC LIMIT 100""", + params, + ) + return list(await cursor.fetchall()) + + +async def process_join_request( + request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None +) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + where = "id = %s AND status = 'PENDING'" + params: tuple[Any, ...] = (request_id,) + if company_id is not None: + where += " AND company_id = %s" + params = (request_id, company_id) + await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) + row = await cursor.fetchone() + if not row: + await connection.rollback() + return False + status = "APPROVED" if approved else "REJECTED" + await cursor.execute( + """UPDATE join_requests SET status = %s, reviewed_by = %s, + reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", + (status, reviewer_id, request_id), + ) + if approved: + # 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정). + role = await role_for_company(int(row["company_id"])) + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s + WHERE id = %s""", + (row["company_id"], role, row["user_id"]), + ) + else: + await cursor.execute( + "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) + ) + await connection.commit() + return True + + +async def list_all_companies() -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_status, + c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" + ) + return list(await cursor.fetchall()) diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 8305bbb6..119651f8 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -1,10 +1,22 @@ """B01_Dashboard 역할별 대시보드 API.""" +import html import mimetypes import os from typing import Any -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Path, + Query, + Response, + UploadFile, +) +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 @@ -17,26 +29,18 @@ from .B01_Dashboard_Repository import ( assign_user_company, change_user_role, count_company_admins, - create_company, get_dashboard_me, get_project, get_system_resources, get_user_admin_target, - get_user_company, - join_company, - list_all_companies, list_all_projects, list_all_users, list_audit_logs, list_company_projects, - list_join_requests, list_user_projects, - process_join_request, - search_companies, soft_delete_project, soft_delete_user, update_admin_user, - update_company, update_project, update_user_profile, ) @@ -51,6 +55,16 @@ from .B01_Dashboard_Repository_Assets import ( update_company_asset, write_company_asset_file, ) +from .B01_Dashboard_Repository_Company import ( + create_company, + get_user_company, + join_company, + list_all_companies, + list_join_requests, + process_join_request, + search_companies, + update_company, +) from .B01_Dashboard_Repository_Members import ( attach_company_member, check_project_refs, @@ -196,7 +210,13 @@ async def company_update( session: dict[str, Any] = Depends(require_company_admin), ): """회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다.""" - if not await update_company(_scope_company(session, company_id), payload.model_dump()): + try: + changed = await update_company(_scope_company(session, company_id), payload.model_dump()) + except IntegrityError as exc: + raise HTTPException( + status_code=409, detail="같은 회사명 또는 사업자등록번호가 이미 있습니다." + ) from exc + if not changed: raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") return {"status": "success"} @@ -216,13 +236,15 @@ async def company_geocode( @router.get("/map/tile/{z}/{x}/{y}") async def map_tile( - z: int, - x: int, - y: int, + z: int = Path(ge=0, le=19), + x: int = Path(ge=0), + y: int = Path(ge=0), session: dict[str, Any] = Depends(verify_session), ): """배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다.""" _ = session + if x >= 2**z or y >= 2**z: + raise HTTPException(status_code=400, detail="지도 타일 번호가 범위를 벗어났습니다.") try: tile = await fetch_base_tile(z, x, y) except Exception as exc: # 지도 한 칸이 비는 것은 화면을 막을 일이 아니다 @@ -289,10 +311,13 @@ async def admin_invite_member( """아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다.""" company = await get_user_company(session.get("company_id")) company_name = (company or {}).get("name") or "회사" + # 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다. + safe_company = html.escape(company_name) + safe_name = html.escape(payload.name or "") sent = await send_email( payload.email, f"[Aislo] {company_name} 팀원 등록 안내", - f"

{payload.name or ''}님, {company_name} 에서 Aislo 팀원으로 등록하려 합니다.

" + f"

{safe_name}님, {safe_company} 에서 Aislo 팀원으로 등록하려 합니다.

" f"

아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.

" f'

{APP_PUBLIC_BASE_URL}

', ) diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 1bafa6f6..9af03c60 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -663,7 +663,7 @@ export function openAddMemberModal(onCreated?: (member: Member) => void): void { openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => { if (!picked) { query.setError("등록할 사람을 먼저 고르십시오."); - throw new Error("member not picked"); + throw new Error("등록할 사람을 먼저 고르십시오."); } const created = await addCompanyMember(picked.id); showToast(L("B01_Dashboard_Saved"), "success");