main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -96,7 +96,8 @@ export async function openEditProjectModal(
|
||||
user: DashboardUser,
|
||||
project: ProjectItem,
|
||||
): Promise<void> {
|
||||
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");
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user