From 2adb5507a4db6035f6a3f4c85cd6c2d2b471df58 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 11:24:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01,B02):=20=ED=8C=80=EC=9B=90=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=A9=EC=8B=9D=20=EA=B5=90=EC=B2=B4=C2=B7?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=82=AD=EC=A0=9C/=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=C2=B7B02=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B01 - 팀원 등록을 계정 대리 생성에서 「소속 없는 기존 가입자 검색·선택」으로 교체, 미가입자는 안내 메일만 발송 - 사용자 계정 삭제 API 신설, 삭제 모달에서 회사 제외/계정 삭제 선택, 관리자 수정 모달에 활성·비활성 상태 지정 - 기본정보 폼과 사용자 수정 폼을 공용 입력칸 한 벌로 통일 (이름 > 직급 > 이메일 > 부서 > 전화) - 프로젝트 담당자 선택의 신규 등록 항목 제거 B02 - 프로젝트명 자동 조합 (사업연도 + 사업지역 + 임도종류 + 직접 입력값) 및 미리보기 표시 - 예상 연장 입력칸 제거, 노선 종료 누가거리 제목에 구간 연장 표기, 표지 값은 프로젝트명·연장에서 파생 - 취소 버튼 추가 (대시보드 복귀), 담당자 기본값을 생성자로 지정 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 30 ++- B01_Dashboard/B01_Dashboard_Repository.py | 20 ++ .../B01_Dashboard_Repository_Members.py | 66 ++--- B01_Dashboard/B01_Dashboard_Router.py | 79 +++++- B01_Dashboard/B01_Dashboard_Schema.py | 12 +- B01_Dashboard/B01_Dashboard_UI_Admin.ts | 19 +- B01_Dashboard/B01_Dashboard_UI_Common.ts | 65 +++++ B01_Dashboard/B01_Dashboard_UI_Modals.ts | 237 ++++++++++-------- B01_Dashboard/B01_Dashboard_UI_Profile.ts | 36 +-- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 124 +++++---- .../B02_ProjRegister_UI_Style.css | 14 ++ 11 files changed, 454 insertions(+), 248 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 94f672e5..448b4f80 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -306,17 +306,35 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; -/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */ -export function addCompanyMember( - email: string, - profile?: { name?: string; position?: string | null; department?: string | null }, -): Promise<{ member: Member }> { +/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */ +export async function searchMemberCandidates(query: string): Promise { + const data = await request<{ users: Member[] }>( + `/dashboard/admin/members/candidates?q=${encodeURIComponent(query)}`, + ); + return data.users; +} + +/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */ +export function addCompanyMember(userId: number): Promise<{ member: Member }> { return request("/dashboard/admin/members", { method: "POST", - body: body({ email, ...profile }), + body: body({ user_id: userId }), }); } +/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */ +export function inviteMember(email: string, name?: string | null): Promise { + return request("/dashboard/admin/members/invite", { + method: "POST", + body: body({ email, name: name || null }), + }); +} + +/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */ +export function deleteDashboardUser(userId: number): Promise { + return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" }); +} + /** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */ export function setCompanyLogo( logoAssetId: number | null, diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 7219638e..d20e76ea 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -598,6 +598,26 @@ async def count_company_admins(company_id: int) -> int: return int(row["cnt"] if row else 0) +async def soft_delete_user(user_id: int) -> bool: + """사용자 계정을 지운다 (2026-09-06 사용자 확정 — 회사 관리자도 자기 회사 사람은 삭제). + + 지운 표시만 남기고 소속을 푼다. 상태가 INACTIVE 라 다음 요청에서 세션이 끊긴다. + 이메일은 그대로 둔다 — 같은 주소로 다시 가입하려면 시스템 관리자가 되살려야 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users + SET deleted_at = CURRENT_TIMESTAMP, status = 'INACTIVE', + company_id = NULL, is_master = FALSE + WHERE id = %s AND deleted_at IS NULL""", + (user_id,), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + async def assign_user_company(user_id: int, company_id: int | None) -> bool: status = "ACTIVE" if company_id else "NO_COMPANY" pool = get_db_pool() diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 394e3797..883ef70e 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -6,13 +6,11 @@ from __future__ import annotations -import secrets from typing import Any import aiomysql from fastapi import HTTPException -from common_util.common_util_auth import hash_password from config.config_db import get_db_pool from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company @@ -34,54 +32,56 @@ async def list_company_members(company_id: int) -> list[dict[str, Any]]: return rows -async def add_company_member( - company_id: int, - email: str, - profile: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """회사에 사람을 붙인다. +async def list_unassigned_users(query: str) -> list[dict[str, Any]]: + """소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정). - `profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다** - (2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는 - 비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다. + 다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다. """ + like = f"%{query.strip()}%" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, position, email, department, status + FROM users + WHERE company_id IS NULL AND deleted_at IS NULL + AND status IN ('NO_COMPANY', 'PENDING') + AND (name LIKE %s OR email LIKE %s) + ORDER BY name, email LIMIT 20""", + (like, like), + ) + return list(await cursor.fetchall()) + + +async def attach_company_member(company_id: int, user_id: int) -> dict[str, Any] | None: + """가입한 사람을 회사에 붙인다. 소속이 이미 있으면 붙이지 않는다.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await connection.begin() await cursor.execute( """SELECT id, company_id FROM users - WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", - (email.lower(),), + WHERE id = %s AND deleted_at IS NULL FOR UPDATE""", + (user_id,), ) user = await cursor.fetchone() - if not user and profile and profile.get("name"): - await cursor.execute( - """INSERT INTO users (email, password_hash, name, position, department, - company_id, role, status) - VALUES (%s, %s, %s, %s, %s, %s, %s, 'PENDING')""", - ( - email.lower(), - hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호 - profile["name"], - profile.get("position"), - profile.get("department"), - company_id, - await role_for_company(company_id), - ), - ) - await connection.commit() - return await get_dashboard_me(cursor.lastrowid) - if not user or user["company_id"] == company_id: + if not user or user["company_id"] is not None: await connection.rollback() return None await cursor.execute( """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s, is_master = FALSE WHERE id = %s""", - (company_id, await role_for_company(company_id), user["id"]), + (company_id, await role_for_company(company_id), user_id), + ) + # 남아 있던 가입 신청은 닫는다 — 목록에 같은 사람이 두 번 보이지 않게 한다. + await cursor.execute( + """UPDATE join_requests + SET status = CASE WHEN company_id = %s THEN 'APPROVED' ELSE 'REJECTED' END, + reviewed_at = CURRENT_TIMESTAMP + WHERE user_id = %s AND status = 'PENDING'""", + (company_id, user_id), ) await connection.commit() - return await get_dashboard_me(user["id"]) + return await get_dashboard_me(user_id) async def remove_company_member(company_id: int, user_id: int) -> bool: diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 8021f8ad..a62e5b48 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -7,9 +7,10 @@ from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile 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 PROJECT_DELETE_HARD_ENABLED +from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( assign_user_company, @@ -32,6 +33,7 @@ from .B01_Dashboard_Repository import ( process_join_request, search_companies, soft_delete_project, + soft_delete_user, update_admin_user, update_project, update_user_profile, @@ -48,9 +50,10 @@ from .B01_Dashboard_Repository_Assets import ( write_company_asset_file, ) from .B01_Dashboard_Repository_Members import ( - add_company_member, + attach_company_member, check_project_refs, list_company_members, + list_unassigned_users, remove_company_member, set_company_logo, ) @@ -60,6 +63,7 @@ from .B01_Dashboard_Schema import ( AssignCompanyRequest, ChangeUserRoleRequest, CreateCompanyRequest, + InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, UpdateCompanyAssetRequest, @@ -206,25 +210,49 @@ async def admin_members( } +@router.get("/admin/members/candidates") +async def admin_member_candidates( + q: str = Query(min_length=2, max_length=100), + session: dict[str, Any] = Depends(require_company_admin), +): + """팀원으로 부를 수 있는 사람 — 소속이 없는 가입자만 (2026-09-06 사용자 확정).""" + _ = session + return {"status": "success", "users": await list_unassigned_users(q)} + + @router.post("/admin/members") async def admin_add_member( payload: AddMemberRequest, session: dict[str, Any] = Depends(require_company_admin), ): - member = await add_company_member( - _require_company_id(session), - payload.email, - { - "name": payload.name, - "position": payload.position, - "department": payload.department, - }, - ) + member = await attach_company_member(_require_company_id(session), payload.user_id) if not member: - raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") + raise HTTPException( + status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다." + ) return {"status": "success", "member": member} +@router.post("/admin/members/invite") +async def admin_invite_member( + payload: InviteMemberRequest, + session: dict[str, Any] = Depends(require_company_admin), +): + """아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다.""" + company = await get_user_company(session.get("company_id")) + company_name = (company or {}).get("name") or "회사" + sent = await send_email( + payload.email, + f"[Aislo] {company_name} 팀원 등록 안내", + f"

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

" + f"

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

" + f'

{APP_PUBLIC_BASE_URL}

', + ) + if not sent: + raise HTTPException(status_code=502, detail="안내 메일을 보내지 못했습니다.") + return {"status": "success"} + + @router.delete("/admin/members/{user_id}") async def admin_remove_member( user_id: int, session: dict[str, Any] = Depends(require_company_admin) @@ -386,6 +414,33 @@ async def admin_update_user( return {"status": "success"} +@router.delete("/admin/users/{user_id}") +async def admin_delete_user( + user_id: int, + session: dict[str, Any] = Depends(require_company_admin), +): + target = await get_user_admin_target(user_id) + if not target: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 지울 수 없습니다.") + if int(session["user_id"]) == user_id: + raise HTTPException(status_code=409, detail="본인 계정은 지울 수 없습니다.") + # 회사에 관리자가 하나도 남지 않게 되는 삭제는 막는다. + if ( + target["role"] == "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", + ) + if not await soft_delete_user(user_id): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + @router.patch("/admin/users/{user_id}/company") async def system_assign_company( user_id: int, diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index b9d791e0..69691dfd 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -24,12 +24,16 @@ class JoinCompanyRequest(BaseModel): class AddMemberRequest(BaseModel): + # 팀원 등록은 「이미 가입한 사람을 고르는」 일이다 (2026-09-06 사용자 확정) — + # 계정을 대신 만들지 않는다. 소속이 없는 가입자만 고를 수 있다. + user_id: int = Field(gt=0) + + +class InviteMemberRequest(BaseModel): + """아직 가입하지 않은 사람에게 보내는 가입 안내 메일.""" + email: str = Field(min_length=3, max_length=255) - # 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면 - # 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다. name: str | None = Field(default=None, max_length=100) - position: str | None = Field(default=None, max_length=100) - department: str | None = Field(default=None, max_length=100) class UpdateCompanyLogoRequest(BaseModel): diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 7874e27b..3da6941b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -1,7 +1,11 @@ import { createButton } from "@ui/ui_template_elements"; import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { canChangeRole } from "./B01_Dashboard_UI_Helper"; -import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals"; +import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; +import { + openChangeRoleModal, + openDeleteUserModal, + openEditUserModal, +} from "./B01_Dashboard_UI_Modals"; import { table, text } from "@ui/ui_template_general_blocks"; import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; @@ -39,6 +43,17 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } + // 회사에서 빼기·계정 삭제 (2026-09-06 사용자 지시) — 범위는 백엔드가 다시 본다. + if (canDeleteUser(currentUser, user) && user.id !== currentUser.id) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_DeleteUser"), + variant: "ghost", + onClick: () => openDeleteUserModal(user), + }), + ); + } + return [ text(user.email), text(user.name), diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 65b6eccd..17f51a19 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { + createInputField, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, @@ -142,3 +143,67 @@ export function attachModalDismiss( return { isDirty, tryClose }; } + +/** + * 사용자 정보 입력칸 한 벌 — 기본정보 폼과 사용자 관리 수정 모달이 같은 것을 쓴다 + * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 + * 이메일은 계정 식별자라 읽기 전용이다. + */ +export function buildUserFields(source: { + name: string; + email?: string; + position?: string | null; + department?: string | null; + phone?: string | null; +}): { + grid: HTMLElement; + validate: () => boolean; + values: () => { + name: string; + position: string | null; + department: string | null; + phone: string | null; + }; +} { + const name = createInputField({ + label: L("B01_Account_Field_Name"), + value: source.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: source.position ?? "", + }); + const email = createInputField({ + label: L("B01_Dashboard_Field_MemberEmail"), + type: "email", + value: source.email ?? "", + }); + email.input.disabled = true; + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: source.department ?? "", + }); + const phone = createInputField({ + label: L("B01_Account_Field_Phone"), + value: source.phone ?? "", + }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(name.root, position.root, email.root, department.root, phone.root); + return { + grid, + validate: () => { + name.setError(); + if (name.input.value.trim()) return true; + name.setError(L("Common_Msg_RequiredField")); + return false; + }, + values: () => ({ + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + }), + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 59845ba2..b98a2d15 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -17,6 +17,9 @@ import { joinCompany, searchCompanies, addCompanyMember, + searchMemberCandidates, + inviteMember, + deleteDashboardUser, fetchCompanyMembers, fetchCompanyAssets, fetchUserCompany, @@ -28,10 +31,11 @@ import { type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; -import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; - -/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ -const NEW_MEMBER = "__new__"; +import { + attachModalDismiss, + buildUserFields, + type ModalDismissHandle, +} from "./B01_Dashboard_UI_Common"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -164,9 +168,10 @@ export async function openEditProjectModal( const personOptions = [ { value: "", text: "(미지정)" }, ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), - { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; const persons: HTMLSelectElement[] = []; + // 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) — + // 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다. const person = (label: string, current: number | null | undefined) => { const field = createSelectField({ label, @@ -174,25 +179,6 @@ export async function openEditProjectModal( value: String(current ?? ""), }); persons.push(field.select); - let last = field.select.value; - // 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다. - field.select.addEventListener("change", () => { - if (field.select.value !== NEW_MEMBER) { - last = field.select.value; - return; - } - field.select.value = last; - openAddMemberModal((member) => { - for (const select of persons) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - select.insertBefore(option, select.options[select.options.length - 1]); - } - field.select.value = String(member.id); - last = field.select.value; - }); - }); return field; }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); @@ -306,22 +292,25 @@ export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem } export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void { - const name = createInputField({ - label: L("B01_Dashboard_Table_Name"), - value: target.name, - required: true, + // 기본정보 폼과 같은 칸 한 벌을 쓴다 (2026-09-06 사용자 지시). + const fields = buildUserFields({ + name: target.name, + email: target.email, + position: target.position, + department: target.department, + phone: (target as DashboardUser).phone, }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: target.position ?? "", + const isAdmin = user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 관리자는 계정을 비활성화할 수 있다 (2026-09-06 사용자 지시) — 비활성 계정은 다음 + // 요청에서 세션이 끊긴다. + const statusField = createSelectField({ + label: L("B01_Dashboard_Table_Status"), + options: [ + { value: "ACTIVE", text: "활성" }, + { value: "INACTIVE", text: "비활성" }, + ], + value: target.status === "INACTIVE" ? "INACTIVE" : "ACTIVE", }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: target.department ?? "", - }); - - const phoneVal = (target as DashboardUser).phone || ""; - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. @@ -330,51 +319,30 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar void fetchCompanyAssets(companyId).then((assets) => { const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id); signatureSlot.append( - createAssetField( - "서명 (도면 표제란)", - "SIGNATURE", - assets, - owned?.id ?? null, - companyId, - user, - { - owner: { id: target.id, name: target.name }, - onChange: async (assetId) => { - if (assetId === null) return; - const picked = assets.find((asset) => asset.id === assetId); - if (picked) - await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); - }, + createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, companyId, user, { + owner: { id: target.id, name: target.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); }, - ).root, + }).root, ); }); - if (user.role === "ADMIN") { - // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 - name.input.disabled = true; - phone.input.disabled = true; - } else if (user.role === "USER" && user.id !== target.id) { - name.input.disabled = true; - position.input.disabled = true; - department.input.disabled = true; - phone.input.disabled = true; - } - - openModal( - L("B01_Dashboard_EditUser"), - [name.root, position.root, department.root, phone.root, signatureSlot], - async () => { - await updateDashboardUser(target.id, { - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - status: target.status, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - }, - ); + const rows = isAdmin + ? [fields.grid, statusField.root, signatureSlot] + : [fields.grid, signatureSlot]; + openModal(L("B01_Dashboard_EditUser"), rows, async () => { + if (!fields.validate()) return; + await updateDashboardUser(target.id, { + ...fields.values(), + // 상태는 관리자만 바꾼다. 그 밖에는 지금 값을 그대로 되돌려 보낸다. + status: isAdmin ? statusField.select.value : target.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }); } export function openChangeRoleModal(target: Member | DashboardUser): void { @@ -394,12 +362,23 @@ export function openChangeRoleModal(target: Member | DashboardUser): void { } export function openDeleteUserModal(target: Member | DashboardUser): void { + // 두 가지가 다르다 — 회사에서 빼면 계정은 남고(무소속), 계정 삭제는 로그인 자체가 막힌다 + // (2026-09-06 사용자 확정). + const mode = createSelectField({ + label: "처리 방식", + options: [ + { value: "REMOVE", text: "회사에서 빼기 (계정은 남음)" }, + { value: "DELETE", text: "계정 삭제 (로그인 불가)" }, + ], + value: "REMOVE", + }); const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); - openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => { - await removeCompanyMember(target.id); + openModal(L("B01_Dashboard_DeleteUser"), [mode.root, warning], async () => { + if (mode.select.value === "DELETE") await deleteDashboardUser(target.id); + else await removeCompanyMember(target.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } @@ -482,32 +461,80 @@ export function openFindCompanyModal(): void { } /** - * 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다** - * (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다. + * 팀원 등록 — 이미 가입한 사람 중 **소속이 없는 사람**만 골라 붙인다 + * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 + * 안내 메일만 보낸다. */ -// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`). -// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것. export function openAddMemberModal(onCreated?: (member: Member) => void): void { - const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), - type: "email", - required: true, + const query = createInputField({ + label: "이름 또는 이메일로 찾기", + placeholder: "두 글자 이상", }); - const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); - const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); - const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); - openModal( - L("B01_Dashboard_Modal_AddMember"), - [email.root, name.root, position.root, department.root], - async () => { - if (!email.input.value.trim()) return; - const created = await addCompanyMember(email.input.value.trim(), { - name: name.input.value.trim() || undefined, - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - if (created?.member) onCreated?.(created.member); + const results = document.createElement("div"); + results.className = "b01-dashboard__actions"; + let picked: Member | null = null; + + const search = createButton({ + label: L("Common_Btn_Search"), + variant: "ghost", + onClick: async function onB01_Member_Search_Click() { + results.innerHTML = ""; + const text = query.input.value.trim(); + if (text.length < 2) { + query.setError("두 글자 이상 입력하십시오."); + return; + } + query.setError(); + const users = await searchMemberCandidates(text); + if (users.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__modal-text"; + empty.textContent = "소속 없는 가입자가 없습니다. 아직 가입 전이면 안내 메일을 보내십시오."; + results.append(empty); + return; + } + for (const candidate of users) { + results.append( + createButton({ + label: `${candidate.name} (${candidate.email})`, + variant: picked?.id === candidate.id ? "filled" : "ghost", + onClick: () => { + picked = candidate; + query.input.value = `${candidate.name} (${candidate.email})`; + results.innerHTML = ""; + }, + }), + ); + } }, - ); + }); + + const invite = createButton({ + label: "가입 안내 메일 보내기", + variant: "ghost", + onClick: async function onB01_Member_Invite_Click() { + const text = query.input.value.trim(); + if (!text.includes("@")) { + query.setError("메일을 보낼 이메일 주소를 입력하십시오."); + return; + } + query.setError(); + await inviteMember(text); + showToast("안내 메일을 보냈습니다.", "success"); + }, + }); + + const bar = document.createElement("div"); + bar.className = "b01-dashboard__actions"; + bar.append(search, invite); + + openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => { + if (!picked) { + query.setError("등록할 사람을 먼저 고르십시오."); + throw new Error("member not picked"); + } + const created = await addCompanyMember(picked.id); + showToast(L("B01_Dashboard_Saved"), "success"); + if (created?.member) onCreated?.(created.member); + }); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index ef110687..90db3ab9 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -1,44 +1,18 @@ import { isBlank } from "@util/common_util_validate"; import { createButton, createInputField } from "@ui/ui_template_elements"; import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { L, runRequest } from "./B01_Dashboard_UI_Common"; +import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; const PASSWORD_MIN_LENGTH = 8; export function buildProfileForm(user: DashboardUser): HTMLElement { - const name = createInputField({ - label: L("B01_Account_Field_Name"), - value: user.name, - required: true, - }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: user.position ?? "", - }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: user.department ?? "", - }); - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); - const grid = document.createElement("div"); - grid.className = "b01-dashboard__form-grid"; - grid.append(name.root, position.root, department.root, phone.root); + const fields = buildUserFields(user); + const grid = fields.grid; const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { - name.setError(); - if (isBlank(name.input.value)) { - name.setError(L("Common_Msg_RequiredField")); - return; - } - await runRequest(() => - updateUserProfile({ - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - }), - ); + if (!fields.validate()) return; + await runRequest(() => updateUserProfile(fields.values())); }, }); const wrap = document.createElement("div"); diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 5609d184..935e3dba 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -27,7 +27,6 @@ import { fetchUserCompany, } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker"; -import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -55,11 +54,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { header.append(title, subtitle); // 입력 필드 + // 프로젝트명은 사업연도 + 사업지역 + 임도종류 + 직접 입력값을 이어 붙여 만든다 + // (2026-09-06 사용자 확정). 아래 미리보기 줄이 저장될 이름 그대로다. const nameField = createInputField({ - label: L("B02_Proj_Field_Name"), - placeholder: L("B02_Proj_Field_Name_Placeholder"), + label: "프로젝트명 (직접 입력 부분)", + placeholder: "예: 가리왕산지구", required: true, }); + const namePreview = document.createElement("p"); + namePreview.className = "b02-proj__preview"; const regionField = createInputField({ label: L("B02_Proj_Field_Region"), placeholder: L("B02_Proj_Field_Region_Placeholder"), @@ -84,12 +87,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { min: 2000, max: currentYear + 5, }); - const lengthField = createInputField({ - label: L("B02_Proj_Field_Length"), - placeholder: L("B02_Proj_Field_Length_Placeholder"), - type: "number", - min: 0, - }); // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). // 둘 다 비우면 전 구간을 쓴다. const routeStartField = createInputField({ @@ -113,14 +110,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다 // (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다. const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" }); - const projectNumberField = createInputField({ - label: "연도·기번 (표지)", - placeholder: "예: 2026년 간선임도(기번3-울진.대흥)", - }); - const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" }); + // 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과 + // 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다. const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); - const NEW_MEMBER = "__new__"; const person = (label: string) => createSelectField({ label, options: [{ value: "", text: "(미지정)" }] }); const pmField = person("과업책임자 (도면 표제란)"); @@ -150,28 +143,9 @@ export function renderB02ProjRegister(root: HTMLElement): void { }; for (const select of personSelects) { for (const member of members) addOption(select, String(member.id), memberText(member)); - // 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다. - if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…"); - } - for (const select of personSelects) { - let last = select.value; - select.addEventListener("change", () => { - if (select.value !== NEW_MEMBER) { - last = select.value; - return; - } - select.value = last; - openAddMemberModal((member) => { - for (const other of personSelects) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - other.insertBefore(option, other.options[other.options.length - 1]); - } - select.value = String(member.id); - last = select.value; - }); - }); + // 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는 + // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. + if (members.some((member) => member.id === me.id)) select.value = String(me.id); } if (company) { // 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다. @@ -188,30 +162,71 @@ export function renderB02ProjRegister(root: HTMLElement): void { } })(); + const roadTypeText = (): string => + roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; + const composedName = (): string => + [ + yearField.input.value.trim(), + regionField.input.value.trim(), + roadTypeText(), + nameField.input.value.trim(), + ] + .filter((part) => part.length > 0) + .join(" "); + const routeLength = (): number | null => { + const start = isBlank(routeStartField.input.value) + ? null + : Number.parseFloat(routeStartField.input.value); + const end = isBlank(routeEndField.input.value) + ? null + : Number.parseFloat(routeEndField.input.value); + if (end === null || !Number.isFinite(end)) return null; + const from = start !== null && Number.isFinite(start) ? start : 0; + return end > from ? end - from : null; + }; + const refresh = (): void => { + namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`; + const length = routeLength(); + routeEndField.root.querySelector(".ui-field__label")!.textContent = + length === null + ? "노선 종료 누가거리 (m)" + : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; + }; + for (const field of [nameField, regionField, yearField, routeStartField, routeEndField]) { + field.input.addEventListener("input", refresh); + } + roadTypeField.select.addEventListener("change", refresh); + refresh(); + const submitBtn = createButton({ label: L("B02_Proj_Submit"), variant: "filled", onClick: onB02_Proj_Submit_Click, }); submitBtn.classList.add("b02-proj__submit"); + const cancelBtn = createButton({ + label: L("Common_Btn_Cancel"), + variant: "ghost", + onClick: function onB02_Proj_Cancel_Click() { + navigateTo(ROUTES.B01_ACCOUNT); + }, + }); const idOrNull = (select: HTMLSelectElement): number | null => - select.value && select.value !== NEW_MEMBER ? Number(select.value) : null; + select.value ? Number(select.value) : null; async function onB02_Proj_Submit_Click(): Promise { nameField.setError(); regionField.setError(); yearField.setError(); - lengthField.setError(); routeStartField.setError(); routeEndField.setError(); // 1차 유효성: 필수값 검사 let hasError = false; const projectYear = Number.parseInt(yearField.input.value, 10); - const estimatedLength = isBlank(lengthField.input.value) - ? null - : Number.parseFloat(lengthField.input.value); + // 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정). + const estimatedLength = routeLength(); if (isBlank(nameField.input.value)) { nameField.setError(L("B02_Proj_Error_Required")); hasError = true; @@ -224,10 +239,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { yearField.setError(L("Common_Validation_NumberRange")); hasError = true; } - if (estimatedLength !== null && (!Number.isFinite(estimatedLength) || estimatedLength < 0)) { - lengthField.setError(L("Common_Validation_NumberRange")); - hasError = true; - } const routeStart = isBlank(routeStartField.input.value) ? null : Number.parseFloat(routeStartField.input.value); @@ -256,7 +267,7 @@ export function renderB02ProjRegister(root: HTMLElement): void { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - name: nameField.input.value.trim(), + name: composedName(), region: regionField.input.value.trim(), road_type: roadTypeField.select.value, project_year: projectYear, @@ -265,8 +276,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { route_end_m: routeEnd, memo: memoField.input.value.trim() || null, client_org: clientOrgField.input.value.trim() || null, - project_number: projectNumberField.input.value.trim() || null, - work_amount: workAmountField.input.value.trim() || null, + // 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정). + project_number: composedName(), + work_amount: + estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`, design_date: designDateField.input.value || null, pm_user_id: idOrNull(pmField.select), field_lead_user_id: idOrNull(fieldLeadField.select), @@ -301,17 +314,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { const grid = document.createElement("div"); grid.className = "b02-proj__grid"; grid.append( - nameField.root, + yearField.root, regionField.root, roadTypeField.root, - yearField.root, - lengthField.root, + nameField.root, + namePreview, routeStartField.root, routeEndField.root, memoField.root, clientOrgField.root, - projectNumberField.root, - workAmountField.root, designDateField.root, pmField.root, fieldLeadField.root, @@ -319,7 +330,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { logoSlot, ); - const card = createCard({ body: [grid, submitBtn], raised: true }); + const actions = document.createElement("div"); + actions.className = "b02-proj__actions"; + actions.append(cancelBtn, submitBtn); + const card = createCard({ body: [grid, actions], raised: true }); page.append(header, card); root.append(page); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index 233f8f2d..97e37525 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -63,3 +63,17 @@ grid-template-columns: 1fr; } } + +/* 조합된 프로젝트명 미리보기 — 저장될 이름 그대로 보인다 (2026-09-06). */ +.b02-proj__preview { + margin: calc(-1 * var(--spacing-8)) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 취소·등록 버튼 줄 */ +.b02-proj__actions { + display: flex; + gap: var(--spacing-8); + justify-content: flex-end; +}