From aed7465a5ff7ee1f40bb8af9039a105fac036bfd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 11:33:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01,B02):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=C2=B7=EA=B0=80=EC=9E=85=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A6=AC=C2=B7=EC=84=9C=EB=AA=85?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_members 표 신설(016 마이그레이션, 기존 프로젝트는 생성자 자동 채움) - 프로젝트 수정 모달에 참여자 선택 추가, 참여자로 지정된 일반사용자는 해당 프로젝트 수정 가능 (백엔드 _can_edit_project 동반 수정) - B02 프로젝트 생성 시 생성자를 참여자로 자동 등록, 참여자도 같은 회사 구성원인지 검증 - 가입 요청 목록을 대기 상태만 표시 (처리 완료 건이 사용자 관리 목록과 중복 노출되던 문제) - 팀원 등록 시 남아 있던 가입 신청 자동 정리 - 서명·로고 선택 문구를 뜻이 드러나게 수정 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 3 ++ B01_Dashboard/B01_Dashboard_Repository.py | 9 +++- .../B01_Dashboard_Repository_Members.py | 44 +++++++++++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 15 +++++-- B01_Dashboard/B01_Dashboard_Schema.py | 3 ++ B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 7 ++- B01_Dashboard/B01_Dashboard_UI_Helper.ts | 5 ++- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 27 +++++++++++- B01_Dashboard/B01_Dashboard_UI_Style.css | 15 +++++++ .../B02_ProjRegister_Repository.py | 5 +++ db_management/016_project_members.sql | 26 +++++++++++ 11 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 db_management/016_project_members.sql diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index d262992f..b2795995 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -59,6 +59,8 @@ export interface ProjectItem { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + /** 참여자 (2026-09-06 사용자 확정) — 여기 든 사람은 일반 사용자여도 수정할 수 있다. */ + member_user_ids?: number[]; owner_name?: string | null; workflow_stage: number; progress_percent: number; @@ -171,6 +173,7 @@ export interface UpdateProjectRequest { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + member_user_ids?: number[] | null; } export interface AdminUpdateUserRequest extends UpdateUserRequest { diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 35aac465..9a3976f3 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -70,11 +70,16 @@ def _project_row(row: dict[str, Any]) -> dict[str, Any]: async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + from .B01_Dashboard_Repository_Members import list_project_member_ids + states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows]) + members = await list_project_member_ids(cursor, [r["id"] for r in rows]) result = [] for r in rows: p_row = _project_row(r) p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []}) + # 참여자는 일반 사용자여도 그 프로젝트를 수정할 수 있다 (2026-09-06 사용자 확정). + p_row["member_user_ids"] = members.get(str(r["id"]), []) result.append(p_row) return result @@ -472,7 +477,9 @@ async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: - where = "WHERE jr.company_id = %s" if company_id else "" + # 처리 끝난 신청은 목록에 남기지 않는다 (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: diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 883ef70e..14a57d54 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -122,6 +122,8 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: user_ids = { data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k) } + # 참여자도 같은 회사 사람이어야 한다 (2026-09-06 사용자 확정). + user_ids |= {int(uid) for uid in (data.get("member_user_ids") or [])} if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}: raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.") wanted = { @@ -133,3 +135,45 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)} if any(kinds.get(data[k]) != kind for k, kind in wanted.items()): raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.") + + +async def list_project_member_ids(cursor: Any, project_ids: list[str]) -> dict[str, list[int]]: + """프로젝트별 참여자 id 목록 (2026-09-06 사용자 확정).""" + if not project_ids: + return {} + marks = ", ".join(["%s"] * len(project_ids)) + await cursor.execute( + f"""SELECT project_id, user_id FROM project_members + WHERE project_id IN ({marks}) ORDER BY user_id""", + tuple(project_ids), + ) + result: dict[str, list[int]] = {} + for row in await cursor.fetchall(): + key = row["project_id"] if isinstance(row, dict) else row[0] + value = row["user_id"] if isinstance(row, dict) else row[1] + result.setdefault(str(key), []).append(int(value)) + return result + + +async def set_project_members(project_id: str, user_ids: list[int]) -> None: + """참여자 목록을 통째로 맞춘다. 만든 사람은 화면에서 늘 포함해 보낸다.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute("DELETE FROM project_members WHERE project_id = %s", (project_id,)) + for user_id in dict.fromkeys(user_ids): + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, int(user_id)), + ) + await connection.commit() + + +async def is_project_member(project_id: str, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT 1 FROM project_members WHERE project_id = %s AND user_id = %s", + (project_id, user_id), + ) + return await cursor.fetchone() is not None diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index c12a3126..8305bbb6 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -54,10 +54,12 @@ from .B01_Dashboard_Repository_Assets import ( from .B01_Dashboard_Repository_Members import ( attach_company_member, check_project_refs, + is_project_member, list_company_members, list_unassigned_users, remove_company_member, set_company_logo, + set_project_members, ) from .B01_Dashboard_Schema import ( AddMemberRequest, @@ -114,12 +116,15 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An return asset -def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: +async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True if session["role"] == "ADMIN": return _same_company(session, project.get("company_id")) - return False + # 참여자로 지정된 일반 사용자도 수정할 수 있다 (2026-09-06 사용자 확정). + return _same_company(session, project.get("company_id")) and await is_project_member( + str(project["id"]), int(session["user_id"]) + ) def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool: @@ -345,7 +350,7 @@ async def dashboard_update_project( project = await get_project(project_id) if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if not _can_edit_project(session, project): + if not await _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). @@ -356,8 +361,12 @@ async def dashboard_update_project( detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", ) await check_project_refs(int(project["company_id"]), data) + member_ids = data.pop("member_user_ids", None) if not await update_project(project_id, data, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if member_ids is not None: + # 만든 사람은 늘 참여자로 남는다. + await set_project_members(project_id, [int(project["user_id"]), *member_ids]) return {"status": "success"} diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 69691dfd..ceab68a7 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -56,6 +56,9 @@ class AssignCompanyRequest(BaseModel): class UpdateProjectRequest(BaseModel): name: str = Field(min_length=1, max_length=255) + # 참여자 (2026-09-06 사용자 확정) — 도면 표제란 3역할과 별개로, 설계에 손대는 사람들. + # 참여자면 일반 사용자도 그 프로젝트를 수정할 수 있다. 비우면 지금 값을 그대로 둔다. + member_user_ids: list[int] | None = Field(default=None) region: str | None = Field(default=None, max_length=100) road_type: str | None = Field(default=None, max_length=100) project_year: int | None = Field(default=None, ge=1900, le=2100) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 1aadb7eb..780028be 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -303,13 +303,16 @@ function openAssetPickerModal( mineBox.checked = owner !== null || kind === "SIGNATURE"; // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. mineBox.disabled = owner !== null; + // 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다. mine.append( mineBox, - document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`), + document.createTextNode( + ` 이 그림을 ${owner ? owner.name : `내 계정(${user.name})`}의 것으로 지정 (풀면 회사 공용)`, + ), ); const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ - label: "올리고 선택", + label: "파일 올리고 이 프로젝트에 쓰기", onClick: async () => { if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts index d4c988f1..fa423208 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Helper.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -1,9 +1,10 @@ import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch"; -export function canEditProject(user: DashboardUser, _project: ProjectItem): boolean { +export function canEditProject(user: DashboardUser, project: ProjectItem): boolean { if (user.role === "SYSTEM_ADMIN") return true; if (user.role === "ADMIN") return user.company_id !== null; - return false; // USER는 수정 불가 + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + return (project.member_user_ids ?? []).includes(user.id); } export function canDeleteProject(user: DashboardUser): boolean { diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index f24fd237..1bafa6f6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -96,7 +96,8 @@ export async function openEditProjectModal( user: DashboardUser, project: ProjectItem, ): Promise { - const isUserOnly = user.role === "USER"; + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + const isUserOnly = user.role === "USER" && !(project.member_user_ids ?? []).includes(user.id); // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). const [members, assets, company] = await Promise.all([ @@ -244,6 +245,27 @@ export async function openEditProjectModal( designer.root, logo.root, ); + // 참여자 — 도면 표제란 3역할과 별개로 설계에 손대는 사람들 (2026-09-06 사용자 확정). + const memberBox = document.createElement("div"); + memberBox.className = "b01-dashboard__members"; + const memberLabel = document.createElement("p"); + memberLabel.className = "b01-dashboard__modal-text"; + memberLabel.textContent = "참여자 (고른 사람은 이 프로젝트를 수정할 수 있음)"; + memberBox.append(memberLabel); + const memberChecks: HTMLInputElement[] = []; + for (const member of members) { + const row = document.createElement("label"); + row.className = "b01-dashboard__member-row"; + const check = document.createElement("input"); + check.type = "checkbox"; + check.value = String(member.id); + check.checked = (project.member_user_ids ?? []).includes(member.id); + check.disabled = isUserOnly; + memberChecks.push(check); + row.append(check, document.createTextNode(` ${memberText(member)}`)); + memberBox.append(row); + } + const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. @@ -251,7 +273,7 @@ export async function openEditProjectModal( openModal( L("B01_Dashboard_EditProject"), - [grid], + [grid, memberBox], async () => { await updateProject(project.id, { name: name.input.value.trim(), @@ -273,6 +295,7 @@ export async function openEditProjectModal( logo_asset_id: logo.value(), // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. signature_asset_id: null, + member_user_ids: memberChecks.filter((c) => c.checked).map((c) => Number(c.value)), }); showToast(L("B01_Dashboard_Saved"), "success"); }, diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 98dc66fd..15fde60c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -301,3 +301,18 @@ border-radius: 50%; background: var(--color-danger, #d33); } + +/* 프로젝트 참여자 고르기 (2026-09-06). */ +.b01-dashboard__members { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + max-height: 160px; + overflow-y: auto; +} + +.b01-dashboard__member-row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 8a184ec7..9c3d68b0 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -102,6 +102,11 @@ async def create_project( fields.get("logo_asset_id"), ), ) + # 만든 사람은 곧 참여자다 (2026-09-06 사용자 확정) — 참여자는 수정 권한을 가진다. + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, user_id), + ) # 워크플로우 단계별 상태 초기화 시드 await initialize_project_stages(cursor, project_id) diff --git a/db_management/016_project_members.sql b/db_management/016_project_members.sql new file mode 100644 index 00000000..6f58721c --- /dev/null +++ b/db_management/016_project_members.sql @@ -0,0 +1,26 @@ +-- 016_project_members.sql +-- 프로젝트 참여자 (2026-09-06 사용자 확정) +-- +-- 도면 표제란에 실리는 이름은 한 사람뿐이지만(과업책임자·분야별책임자·설계자), 설계 +-- 과정에서 손을 대는 보조 인원은 여러 명일 수 있다. 그 사람들을 담는 표다. +-- +-- 참여자는 일반 사용자여도 그 프로젝트를 **수정할 수 있다**. 만든 사람은 등록 시점에 +-- 자동으로 참여자가 된다. + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id', + user_id INT NOT NULL COMMENT 'users.id', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + KEY idx_project_members_user (user_id), + CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='프로젝트 참여자'; + +-- 기존 프로젝트는 만든 사람을 참여자로 채워 둔다. +INSERT IGNORE INTO project_members (project_id, user_id) +SELECT id, user_id FROM projects WHERE deleted_at IS NULL;