main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12

Merged
eomsangdon merged 585 commits from main_laptop_1 into main 2026-09-08 17:26:30 +09:00
11 changed files with 149 additions and 10 deletions
Showing only changes of commit aed7465a5f - Show all commits
+3
View File
@@ -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 {
+8 -1
View File
@@ -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
+12 -3
View File
@@ -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"}
+3
View File
@@ -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;
+3 -2
View File
@@ -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 {
+25 -2
View File
@@ -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");
},
+15
View File
@@ -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)
+26
View File
@@ -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;