feat(B01,B02): 팀원 등록 방식 교체·사용자 삭제/비활성화·B02 등록 화면 정비
B01 - 팀원 등록을 계정 대리 생성에서 「소속 없는 기존 가입자 검색·선택」으로 교체, 미가입자는 안내 메일만 발송 - 사용자 계정 삭제 API 신설, 삭제 모달에서 회사 제외/계정 삭제 선택, 관리자 수정 모달에 활성·비활성 상태 지정 - 기본정보 폼과 사용자 수정 폼을 공용 입력칸 한 벌로 통일 (이름 > 직급 > 이메일 > 부서 > 전화) - 프로젝트 담당자 선택의 신규 등록 항목 제거 B02 - 프로젝트명 자동 조합 (사업연도 + 사업지역 + 임도종류 + 직접 입력값) 및 미리보기 표시 - 예상 연장 입력칸 제거, 노선 종료 누가거리 제목에 구간 연장 표기, 표지 값은 프로젝트명·연장에서 파생 - 취소 버튼 추가 (대시보드 복귀), 담당자 기본값을 생성자로 지정 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -306,17 +306,35 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||
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<Member[]> {
|
||||
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<unknown> {
|
||||
return request("/dashboard/admin/members/invite", {
|
||||
method: "POST",
|
||||
body: body({ email, name: name || null }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */
|
||||
export function deleteDashboardUser(userId: number): Promise<unknown> {
|
||||
return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */
|
||||
export function setCompanyLogo(
|
||||
logoAssetId: number | null,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"<p>{payload.name or ''}님, {company_name} 에서 Aislo 팀원으로 등록하려 합니다.</p>"
|
||||
f"<p>아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.</p>"
|
||||
f'<p><a href="{APP_PUBLIC_BASE_URL}">{APP_PUBLIC_BASE_URL}</a></p>',
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user