Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -68,6 +68,7 @@ export interface CompanyInfo {
|
|||||||
business_address?: string | null;
|
business_address?: string | null;
|
||||||
business_owner?: string | null;
|
business_owner?: string | null;
|
||||||
business_status?: string | null;
|
business_status?: string | null;
|
||||||
|
logo_asset_id?: number | null;
|
||||||
user_count?: number;
|
user_count?: number;
|
||||||
project_count?: number;
|
project_count?: number;
|
||||||
}
|
}
|
||||||
@@ -232,7 +233,9 @@ export async function searchCompanies(query: string): Promise<CompanyInfo[]> {
|
|||||||
return data.companies;
|
return data.companies;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCompany(payload: CreateCompanyRequest): Promise<unknown> {
|
export function createCompany(
|
||||||
|
payload: CreateCompanyRequest,
|
||||||
|
): Promise<{ company_id: number; status: string }> {
|
||||||
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
return request("/dashboard/user/company/create", { method: "POST", body: body(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +280,17 @@ export async function createCompanyAsset(form: FormData): Promise<number> {
|
|||||||
return data.asset_id;
|
return data.asset_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 자산의 이름·주인을 고친다. 서명을 사람 계정에 물릴 때 쓴다 (2026-09-02). */
|
||||||
|
export function updateCompanyAsset(
|
||||||
|
assetId: number,
|
||||||
|
payload: { label: string; user_id: number | null },
|
||||||
|
): Promise<unknown> {
|
||||||
|
return request(`/dashboard/company/assets/${assetId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: body(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||||
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
|
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
@@ -284,8 +298,26 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
|||||||
export const companyAssetFileUrl = (assetId: number): string =>
|
export const companyAssetFileUrl = (assetId: number): string =>
|
||||||
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
||||||
|
|
||||||
export function addCompanyMember(email: string): Promise<unknown> {
|
/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */
|
||||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
export function addCompanyMember(
|
||||||
|
email: string,
|
||||||
|
profile?: { name?: string; position?: string | null; department?: string | null },
|
||||||
|
): Promise<{ member: Member }> {
|
||||||
|
return request("/dashboard/admin/members", {
|
||||||
|
method: "POST",
|
||||||
|
body: body({ email, ...profile }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */
|
||||||
|
export function setCompanyLogo(
|
||||||
|
logoAssetId: number | null,
|
||||||
|
companyId?: number | null,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return request(`/dashboard/company/logo${companyQuery(companyId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: body({ logo_asset_id: logoAssetId }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeCompanyMember(userId: number): Promise<unknown> {
|
export function removeCompanyMember(userId: number): Promise<unknown> {
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
|
|||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
|
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
|
||||||
c.business_owner, c.business_status,
|
c.business_owner, c.business_status, c.logo_asset_id,
|
||||||
COUNT(DISTINCT u.id) AS user_count,
|
COUNT(DISTINCT u.id) AS user_count,
|
||||||
COUNT(DISTINCT p.id) AS project_count
|
COUNT(DISTINCT p.id) AS project_count
|
||||||
FROM companies c
|
FROM companies c
|
||||||
@@ -409,58 +409,6 @@ async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
|
|
||||||
pool = get_db_pool()
|
|
||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
|
||||||
await cursor.execute(
|
|
||||||
"""SELECT id, email, name, position, department, role, is_master, status
|
|
||||||
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
|
||||||
(company_id,),
|
|
||||||
)
|
|
||||||
rows = list(await cursor.fetchall())
|
|
||||||
for row in rows:
|
|
||||||
row["role"] = _role(row.get("role"))
|
|
||||||
row["is_master"] = bool(row.get("is_master"))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
async def add_company_member(company_id: int, email: str) -> 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(),),
|
|
||||||
)
|
|
||||||
user = await cursor.fetchone()
|
|
||||||
if not user or user["company_id"] == company_id:
|
|
||||||
await connection.rollback()
|
|
||||||
return None
|
|
||||||
await cursor.execute(
|
|
||||||
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
|
||||||
is_master = FALSE
|
|
||||||
WHERE id = %s""",
|
|
||||||
(company_id, user["id"]),
|
|
||||||
)
|
|
||||||
await connection.commit()
|
|
||||||
return await get_dashboard_me(user["id"])
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
|
||||||
pool = get_db_pool()
|
|
||||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
|
||||||
await cursor.execute(
|
|
||||||
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
|
||||||
is_master = FALSE
|
|
||||||
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
|
||||||
(user_id, company_id),
|
|
||||||
)
|
|
||||||
changed = cursor.rowcount > 0
|
|
||||||
await connection.commit()
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
|
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
where = "WHERE jr.company_id = %s" if company_id else ""
|
where = "WHERE jr.company_id = %s" if company_id else ""
|
||||||
params = (company_id,) if company_id else ()
|
params = (company_id,) if company_id else ()
|
||||||
@@ -520,7 +468,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
|
|||||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
await cursor.execute(
|
await cursor.execute(
|
||||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
||||||
c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||||
COUNT(DISTINCT p.id) AS project_count
|
COUNT(DISTINCT p.id) AS project_count
|
||||||
FROM companies c
|
FROM companies c
|
||||||
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한).
|
||||||
|
|
||||||
|
구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라
|
||||||
|
자산·로고와 같은 결로 묶어 둔다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
from .B01_Dashboard_Repository_Assets import list_company_assets
|
||||||
|
|
||||||
|
|
||||||
|
async def list_company_members(company_id: int) -> list[dict[str, Any]]:
|
||||||
|
pool = get_db_pool()
|
||||||
|
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"""SELECT id, email, name, position, department, role, is_master, status
|
||||||
|
FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""",
|
||||||
|
(company_id,),
|
||||||
|
)
|
||||||
|
rows = list(await cursor.fetchall())
|
||||||
|
for row in rows:
|
||||||
|
row["role"] = _role(row.get("role"))
|
||||||
|
row["is_master"] = bool(row.get("is_master"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
async def add_company_member(
|
||||||
|
company_id: int,
|
||||||
|
email: str,
|
||||||
|
profile: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""회사에 사람을 붙인다.
|
||||||
|
|
||||||
|
`profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다**
|
||||||
|
(2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는
|
||||||
|
비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다.
|
||||||
|
"""
|
||||||
|
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(),),
|
||||||
|
)
|
||||||
|
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, 'USER', 'PENDING')""",
|
||||||
|
(
|
||||||
|
email.lower(),
|
||||||
|
hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호
|
||||||
|
profile["name"],
|
||||||
|
profile.get("position"),
|
||||||
|
profile.get("department"),
|
||||||
|
company_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await connection.commit()
|
||||||
|
return await get_dashboard_me(cursor.lastrowid)
|
||||||
|
if not user or user["company_id"] == company_id:
|
||||||
|
await connection.rollback()
|
||||||
|
return None
|
||||||
|
await cursor.execute(
|
||||||
|
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
|
||||||
|
is_master = FALSE
|
||||||
|
WHERE id = %s""",
|
||||||
|
(company_id, user["id"]),
|
||||||
|
)
|
||||||
|
await connection.commit()
|
||||||
|
return await get_dashboard_me(user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_company_member(company_id: int, user_id: int) -> bool:
|
||||||
|
pool = get_db_pool()
|
||||||
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"""UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER',
|
||||||
|
is_master = FALSE
|
||||||
|
WHERE id = %s AND company_id = %s AND is_master = FALSE""",
|
||||||
|
(user_id, company_id),
|
||||||
|
)
|
||||||
|
changed = cursor.rowcount > 0
|
||||||
|
await connection.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def set_company_logo(company_id: int, asset_id: int | None) -> bool:
|
||||||
|
"""회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경).
|
||||||
|
|
||||||
|
`asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다.
|
||||||
|
"""
|
||||||
|
pool = get_db_pool()
|
||||||
|
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(asset_id, company_id),
|
||||||
|
)
|
||||||
|
changed = cursor.rowcount > 0
|
||||||
|
await connection.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def check_project_refs(company_id: int, data: dict[str, Any]) -> None:
|
||||||
|
"""담당자·로고·서명은 그 회사의 것만 물린다 (2026-09-02 사용자 확정).
|
||||||
|
|
||||||
|
프로젝트 수정(B01)과 등록(B02)이 같은 규칙을 써야 해서 저장소에 둔다.
|
||||||
|
"""
|
||||||
|
user_ids = {
|
||||||
|
data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k)
|
||||||
|
}
|
||||||
|
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 = {
|
||||||
|
k: kind
|
||||||
|
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
|
||||||
|
if data.get(k)
|
||||||
|
}
|
||||||
|
if wanted:
|
||||||
|
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="로고·서명은 같은 회사 자산이어야 합니다.")
|
||||||
@@ -6,13 +6,12 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||||
|
|
||||||
from common_util.common_util_auth import require_system_admin, verify_session
|
from common_util.common_util_auth import require_company, require_system_admin, verify_session
|
||||||
from common_util.common_util_project_delete import hard_delete_project
|
from common_util.common_util_project_delete import hard_delete_project
|
||||||
from common_util.common_util_storage import read_stored_asset
|
from common_util.common_util_storage import read_stored_asset
|
||||||
from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
||||||
|
|
||||||
from .B01_Dashboard_Repository import (
|
from .B01_Dashboard_Repository import (
|
||||||
add_company_member,
|
|
||||||
assign_user_company,
|
assign_user_company,
|
||||||
change_user_role,
|
change_user_role,
|
||||||
create_company,
|
create_company,
|
||||||
@@ -26,12 +25,10 @@ from .B01_Dashboard_Repository import (
|
|||||||
list_all_projects,
|
list_all_projects,
|
||||||
list_all_users,
|
list_all_users,
|
||||||
list_audit_logs,
|
list_audit_logs,
|
||||||
list_company_members,
|
|
||||||
list_company_projects,
|
list_company_projects,
|
||||||
list_join_requests,
|
list_join_requests,
|
||||||
list_user_projects,
|
list_user_projects,
|
||||||
process_join_request,
|
process_join_request,
|
||||||
remove_company_member,
|
|
||||||
search_companies,
|
search_companies,
|
||||||
soft_delete_project,
|
soft_delete_project,
|
||||||
update_admin_user,
|
update_admin_user,
|
||||||
@@ -49,6 +46,13 @@ from .B01_Dashboard_Repository_Assets import (
|
|||||||
update_company_asset,
|
update_company_asset,
|
||||||
write_company_asset_file,
|
write_company_asset_file,
|
||||||
)
|
)
|
||||||
|
from .B01_Dashboard_Repository_Members import (
|
||||||
|
add_company_member,
|
||||||
|
check_project_refs,
|
||||||
|
list_company_members,
|
||||||
|
remove_company_member,
|
||||||
|
set_company_logo,
|
||||||
|
)
|
||||||
from .B01_Dashboard_Schema import (
|
from .B01_Dashboard_Schema import (
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
AdminUpdateUserRequest,
|
AdminUpdateUserRequest,
|
||||||
@@ -58,6 +62,7 @@ from .B01_Dashboard_Schema import (
|
|||||||
JoinCompanyRequest,
|
JoinCompanyRequest,
|
||||||
ProcessJoinRequest,
|
ProcessJoinRequest,
|
||||||
UpdateCompanyAssetRequest,
|
UpdateCompanyAssetRequest,
|
||||||
|
UpdateCompanyLogoRequest,
|
||||||
UpdateProjectRequest,
|
UpdateProjectRequest,
|
||||||
UpdateUserRequest,
|
UpdateUserRequest,
|
||||||
)
|
)
|
||||||
@@ -102,25 +107,6 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An
|
|||||||
return asset
|
return asset
|
||||||
|
|
||||||
|
|
||||||
async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None:
|
|
||||||
"""담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정)."""
|
|
||||||
company_id = int(project["company_id"])
|
|
||||||
user_ids = {
|
|
||||||
data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k]
|
|
||||||
}
|
|
||||||
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 = {
|
|
||||||
k: kind
|
|
||||||
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
|
|
||||||
if data[k]
|
|
||||||
}
|
|
||||||
if wanted:
|
|
||||||
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="로고·서명은 같은 회사 자산이어야 합니다.")
|
|
||||||
|
|
||||||
|
|
||||||
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||||
if session["role"] == "SYSTEM_ADMIN":
|
if session["role"] == "SYSTEM_ADMIN":
|
||||||
return True
|
return True
|
||||||
@@ -196,8 +182,10 @@ async def user_company_join(
|
|||||||
@router.get("/admin/members")
|
@router.get("/admin/members")
|
||||||
async def admin_members(
|
async def admin_members(
|
||||||
company_id: int | None = Query(None, gt=0),
|
company_id: int | None = Query(None, gt=0),
|
||||||
session: dict[str, Any] = Depends(require_company_admin),
|
session: dict[str, Any] = Depends(require_company),
|
||||||
):
|
):
|
||||||
|
# 읽기는 회사 구성원 누구나 — B02 등록 화면에서 일반 사용자도 담당자를 골라야 한다
|
||||||
|
# (2026-09-02 사용자 지시). 쓰기(POST·DELETE)는 관리자 그대로다.
|
||||||
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
|
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -210,7 +198,15 @@ async def admin_add_member(
|
|||||||
payload: AddMemberRequest,
|
payload: AddMemberRequest,
|
||||||
session: dict[str, Any] = Depends(require_company_admin),
|
session: dict[str, Any] = Depends(require_company_admin),
|
||||||
):
|
):
|
||||||
member = await add_company_member(_require_company_id(session), payload.email)
|
member = await add_company_member(
|
||||||
|
_require_company_id(session),
|
||||||
|
payload.email,
|
||||||
|
{
|
||||||
|
"name": payload.name,
|
||||||
|
"position": payload.position,
|
||||||
|
"department": payload.department,
|
||||||
|
},
|
||||||
|
)
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
|
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
|
||||||
return {"status": "success", "member": member}
|
return {"status": "success", "member": member}
|
||||||
@@ -268,7 +264,7 @@ async def dashboard_update_project(
|
|||||||
if not _can_edit_project(session, project):
|
if not _can_edit_project(session, project):
|
||||||
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
|
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
await _check_project_refs(project, data)
|
await check_project_refs(int(project["company_id"]), data)
|
||||||
if not await update_project(project_id, data, int(session["user_id"])):
|
if not await update_project(project_id, data, int(session["user_id"])):
|
||||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
@@ -475,6 +471,26 @@ async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(
|
|||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/company/logo")
|
||||||
|
async def company_set_logo(
|
||||||
|
payload: UpdateCompanyLogoRequest,
|
||||||
|
company_id: int | None = Query(None, gt=0),
|
||||||
|
session: dict[str, Any] = Depends(require_company_admin),
|
||||||
|
):
|
||||||
|
"""회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정).
|
||||||
|
|
||||||
|
프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다.
|
||||||
|
"""
|
||||||
|
scoped = _scope_company(session, company_id)
|
||||||
|
if payload.logo_asset_id is not None:
|
||||||
|
asset = await get_company_asset(payload.logo_asset_id)
|
||||||
|
if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO":
|
||||||
|
raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.")
|
||||||
|
if not await set_company_logo(scoped, payload.logo_asset_id):
|
||||||
|
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/company/assets/{asset_id}/file")
|
@router.get("/company/assets/{asset_id}/file")
|
||||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ class JoinCompanyRequest(BaseModel):
|
|||||||
|
|
||||||
class AddMemberRequest(BaseModel):
|
class AddMemberRequest(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=255)
|
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):
|
||||||
|
# 회사 대표 로고 (company_assets.id, kind=LOGO). None 이면 지정을 지운다.
|
||||||
|
logo_asset_id: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class ProcessJoinRequest(BaseModel):
|
class ProcessJoinRequest(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export interface AssetFieldHandle {
|
|||||||
value: () => number | null;
|
value: () => number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetFieldOptions {
|
||||||
|
/** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */
|
||||||
|
owner?: { id: number; name: string } | null;
|
||||||
|
/** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */
|
||||||
|
onChange?: (assetId: number | null) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
||||||
|
|
||||||
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
||||||
@@ -30,8 +37,14 @@ export function createAssetField(
|
|||||||
initialId: number | null | undefined,
|
initialId: number | null | undefined,
|
||||||
companyId: number | null | undefined,
|
companyId: number | null | undefined,
|
||||||
user: DashboardUser,
|
user: DashboardUser,
|
||||||
|
options: AssetFieldOptions = {},
|
||||||
): AssetFieldHandle {
|
): AssetFieldHandle {
|
||||||
let list = assets.filter((asset) => asset.kind === kind);
|
const owner = options.owner ?? null;
|
||||||
|
// 주인이 못박힌 칸은 그 사람 것(또는 아직 주인 없는 것)만 보인다.
|
||||||
|
let list = assets.filter(
|
||||||
|
(asset) =>
|
||||||
|
asset.kind === kind && (!owner || asset.user_id === owner.id || asset.user_id == null),
|
||||||
|
);
|
||||||
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
||||||
|
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
@@ -58,17 +71,124 @@ export function createAssetField(
|
|||||||
label: "선택…",
|
label: "선택…",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => {
|
openAssetPickerModal(
|
||||||
list = fresh;
|
kind,
|
||||||
selected = next;
|
list,
|
||||||
render();
|
selected?.id ?? null,
|
||||||
}),
|
companyId,
|
||||||
|
user,
|
||||||
|
(next, fresh) => {
|
||||||
|
list = fresh;
|
||||||
|
selected = next;
|
||||||
|
render();
|
||||||
|
void options.onChange?.(next?.id ?? null);
|
||||||
|
},
|
||||||
|
owner,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
row.append(preview, name, pick);
|
row.append(preview, name, pick);
|
||||||
root.append(caption, row);
|
root.append(caption, row);
|
||||||
return { root, value: () => selected?.id ?? null };
|
return { root, value: () => selected?.id ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다.
|
||||||
|
* 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다.
|
||||||
|
*/
|
||||||
|
function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise<File | null> } {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "ui-field";
|
||||||
|
const caption = document.createElement("label");
|
||||||
|
caption.className = "ui-field__label";
|
||||||
|
caption.textContent = "또는 마우스로 그리기";
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 480;
|
||||||
|
canvas.height = 160;
|
||||||
|
canvas.className = "b01-dashboard__sign-pad";
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
let drawn = false;
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
context.lineWidth = 2.5;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.strokeStyle = "#111111";
|
||||||
|
}
|
||||||
|
// 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다.
|
||||||
|
const at = (event: PointerEvent): [number, number] => {
|
||||||
|
const box = canvas.getBoundingClientRect();
|
||||||
|
return [
|
||||||
|
((event.clientX - box.left) * canvas.width) / box.width,
|
||||||
|
((event.clientY - box.top) * canvas.height) / box.height,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
if (!context) return;
|
||||||
|
drawing = true;
|
||||||
|
drawn = true;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
const [x, y] = at(event);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointermove", (event) => {
|
||||||
|
if (!drawing || !context) return;
|
||||||
|
const [x, y] = at(event);
|
||||||
|
context.lineTo(x, y);
|
||||||
|
context.stroke();
|
||||||
|
});
|
||||||
|
const stop = (): void => {
|
||||||
|
drawing = false;
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerup", stop);
|
||||||
|
canvas.addEventListener("pointercancel", stop);
|
||||||
|
|
||||||
|
const clear = createButton({
|
||||||
|
label: "지우기",
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => {
|
||||||
|
context?.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
drawn = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
root.append(caption, canvas, clear);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
toFile: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (!drawn) return resolve(null);
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null),
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자산 고르기 모달을 버튼 하나로 연다 — 표 안(회사 목록)처럼 칸을 둘 자리가 없을 때.
|
||||||
|
* 자산 목록은 열 때 받는다(회사가 여럿이면 미리 다 받을 이유가 없다).
|
||||||
|
*/
|
||||||
|
export async function openAssetPicker(
|
||||||
|
kind: CompanyAsset["kind"],
|
||||||
|
companyId: number,
|
||||||
|
user: DashboardUser,
|
||||||
|
currentId: number | null,
|
||||||
|
onPick: (assetId: number | null) => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const assets = await fetchCompanyAssets(companyId);
|
||||||
|
openAssetPickerModal(
|
||||||
|
kind,
|
||||||
|
assets.filter((asset) => asset.kind === kind),
|
||||||
|
currentId,
|
||||||
|
companyId,
|
||||||
|
user,
|
||||||
|
(next) => void onPick(next?.id ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openAssetPickerModal(
|
function openAssetPickerModal(
|
||||||
kind: CompanyAsset["kind"],
|
kind: CompanyAsset["kind"],
|
||||||
assets: CompanyAsset[],
|
assets: CompanyAsset[],
|
||||||
@@ -76,6 +196,7 @@ function openAssetPickerModal(
|
|||||||
companyId: number | null | undefined,
|
companyId: number | null | undefined,
|
||||||
user: DashboardUser,
|
user: DashboardUser,
|
||||||
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
||||||
|
owner: { id: number; name: string } | null = null,
|
||||||
): void {
|
): void {
|
||||||
let list = assets;
|
let list = assets;
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
@@ -150,19 +271,29 @@ function openAssetPickerModal(
|
|||||||
mine.className = "b01-dashboard__check";
|
mine.className = "b01-dashboard__check";
|
||||||
const mineBox = document.createElement("input");
|
const mineBox = document.createElement("input");
|
||||||
mineBox.type = "checkbox";
|
mineBox.type = "checkbox";
|
||||||
mineBox.checked = kind === "SIGNATURE";
|
mineBox.checked = owner !== null || kind === "SIGNATURE";
|
||||||
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
|
// 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다.
|
||||||
|
mineBox.disabled = owner !== null;
|
||||||
|
mine.append(
|
||||||
|
mineBox,
|
||||||
|
document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`),
|
||||||
|
);
|
||||||
|
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
|
||||||
const add = createButton({
|
const add = createButton({
|
||||||
label: "올리고 선택",
|
label: "올리고 선택",
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const chosen = file.input.files?.[0];
|
|
||||||
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
||||||
if (!chosen) return file.setError("그림 파일을 고르세요.");
|
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
|
||||||
|
if (!chosen) {
|
||||||
|
return file.setError(
|
||||||
|
pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("kind", kind);
|
form.append("kind", kind);
|
||||||
form.append("label", label.input.value.trim());
|
form.append("label", label.input.value.trim());
|
||||||
form.append("file", chosen);
|
form.append("file", chosen);
|
||||||
if (mineBox.checked) form.append("user_id", String(user.id));
|
if (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id));
|
||||||
if (companyId) form.append("company_id", String(companyId));
|
if (companyId) form.append("company_id", String(companyId));
|
||||||
try {
|
try {
|
||||||
const id = await createCompanyAsset(form);
|
const id = await createCompanyAsset(form);
|
||||||
@@ -178,7 +309,9 @@ function openAssetPickerModal(
|
|||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "b01-dashboard__actions";
|
actions.className = "b01-dashboard__actions";
|
||||||
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
||||||
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
|
panel.append(heading, grid, addTitle, label.root, file.root);
|
||||||
|
if (pad) panel.append(pad.root);
|
||||||
|
panel.append(mine, add, actions);
|
||||||
modal.append(panel);
|
modal.append(panel);
|
||||||
document.body.append(modal);
|
document.body.append(modal);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||||
import {
|
import {
|
||||||
|
fetchCompanyAssets,
|
||||||
processJoinRequest,
|
processJoinRequest,
|
||||||
|
setCompanyLogo,
|
||||||
type CompanyInfo,
|
type CompanyInfo,
|
||||||
type DashboardUser,
|
type DashboardUser,
|
||||||
type JoinRequest,
|
type JoinRequest,
|
||||||
type Member,
|
type Member,
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} from "./B01_Dashboard_Api_Fetch";
|
||||||
|
import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker";
|
||||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||||
import {
|
import {
|
||||||
openChangeRoleModal,
|
openChangeRoleModal,
|
||||||
@@ -41,6 +44,26 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
|||||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||||
);
|
);
|
||||||
|
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||||
|
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||||
|
if (state.user.role !== "USER") {
|
||||||
|
const company = state.company;
|
||||||
|
const slot = document.createElement("div");
|
||||||
|
void fetchCompanyAssets(company.id).then((assets) => {
|
||||||
|
slot.append(
|
||||||
|
createAssetField(
|
||||||
|
"회사 로고 (도면 표제란)",
|
||||||
|
"LOGO",
|
||||||
|
assets,
|
||||||
|
company.logo_asset_id ?? null,
|
||||||
|
company.id,
|
||||||
|
state.user,
|
||||||
|
{ onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) },
|
||||||
|
).root,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrap.append(slot);
|
||||||
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,17 +145,40 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function companyTable(companies: CompanyInfo[]): HTMLElement {
|
export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement {
|
||||||
|
// 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||||
|
const logoCell = (company: CompanyInfo): HTMLElement => {
|
||||||
|
if (!user) return text("");
|
||||||
|
const button = createButton({
|
||||||
|
label: company.logo_asset_id ? "로고 변경…" : "로고 지정…",
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () =>
|
||||||
|
void openAssetPicker(
|
||||||
|
"LOGO",
|
||||||
|
company.id,
|
||||||
|
user,
|
||||||
|
company.logo_asset_id ?? null,
|
||||||
|
async (id) => {
|
||||||
|
await setCompanyLogo(id, company.id);
|
||||||
|
company.logo_asset_id = id;
|
||||||
|
button.textContent = id ? "로고 변경…" : "로고 지정…";
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
};
|
||||||
return table(
|
return table(
|
||||||
[
|
[
|
||||||
L("B01_Dashboard_Table_Company"),
|
L("B01_Dashboard_Table_Company"),
|
||||||
L("B01_Dashboard_Field_BusinessNumber"),
|
L("B01_Dashboard_Field_BusinessNumber"),
|
||||||
L("B01_Dashboard_Table_Status"),
|
L("B01_Dashboard_Table_Status"),
|
||||||
|
"로고",
|
||||||
],
|
],
|
||||||
companies.map((company) => [
|
companies.map((company) => [
|
||||||
text(company.name),
|
text(company.name),
|
||||||
text(company.business_registration_number),
|
text(company.business_registration_number),
|
||||||
text(company.business_status),
|
text(company.business_status),
|
||||||
|
logoCell(company),
|
||||||
]),
|
]),
|
||||||
DASHBOARD_VISIBLE_ROWS,
|
DASHBOARD_VISIBLE_ROWS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import {
|
|||||||
addCompanyMember,
|
addCompanyMember,
|
||||||
fetchCompanyMembers,
|
fetchCompanyMembers,
|
||||||
fetchCompanyAssets,
|
fetchCompanyAssets,
|
||||||
|
updateCompanyAsset,
|
||||||
|
createCompanyAsset,
|
||||||
|
setCompanyLogo,
|
||||||
type DashboardUser,
|
type DashboardUser,
|
||||||
type ProjectItem,
|
type ProjectItem,
|
||||||
type Member,
|
type Member,
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} from "./B01_Dashboard_Api_Fetch";
|
||||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||||
|
|
||||||
|
/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */
|
||||||
|
const NEW_MEMBER = "__new__";
|
||||||
|
|
||||||
function L(key: keyof typeof ui_locales): string {
|
function L(key: keyof typeof ui_locales): string {
|
||||||
return ui_locales[key][currentLanguageIndex];
|
return ui_locales[key][currentLanguageIndex];
|
||||||
}
|
}
|
||||||
@@ -122,15 +128,42 @@ export async function openEditProjectModal(
|
|||||||
type: "date",
|
type: "date",
|
||||||
value: (project.design_date ?? "").slice(0, 10),
|
value: (project.design_date ?? "").slice(0, 10),
|
||||||
});
|
});
|
||||||
|
const memberText = (member: Member) =>
|
||||||
|
member.position ? `${member.name} (${member.position})` : member.name;
|
||||||
const personOptions = [
|
const personOptions = [
|
||||||
{ value: "", text: "(미지정)" },
|
{ value: "", text: "(미지정)" },
|
||||||
...members.map((member) => ({
|
...members.map((member) => ({ value: String(member.id), text: memberText(member) })),
|
||||||
value: String(member.id),
|
{ value: NEW_MEMBER, text: "+ 신규 등록…" },
|
||||||
text: member.position ? `${member.name} (${member.position})` : member.name,
|
|
||||||
})),
|
|
||||||
];
|
];
|
||||||
const person = (label: string, current: number | null | undefined) =>
|
const persons: HTMLSelectElement[] = [];
|
||||||
createSelectField({ label, options: personOptions, value: String(current ?? "") });
|
const person = (label: string, current: number | null | undefined) => {
|
||||||
|
const field = createSelectField({
|
||||||
|
label,
|
||||||
|
options: personOptions,
|
||||||
|
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);
|
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
|
||||||
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
|
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
|
||||||
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
|
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
|
||||||
@@ -142,15 +175,6 @@ export async function openEditProjectModal(
|
|||||||
project.company_id,
|
project.company_id,
|
||||||
user,
|
user,
|
||||||
);
|
);
|
||||||
const signature = createAssetField(
|
|
||||||
"설계자 서명 (도면 표제란)",
|
|
||||||
"SIGNATURE",
|
|
||||||
assets,
|
|
||||||
project.signature_asset_id,
|
|
||||||
project.company_id,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isUserOnly) {
|
if (isUserOnly) {
|
||||||
name.input.disabled = true;
|
name.input.disabled = true;
|
||||||
region.input.disabled = true;
|
region.input.disabled = true;
|
||||||
@@ -185,7 +209,6 @@ export async function openEditProjectModal(
|
|||||||
fieldLead.root,
|
fieldLead.root,
|
||||||
designer.root,
|
designer.root,
|
||||||
logo.root,
|
logo.root,
|
||||||
signature.root,
|
|
||||||
);
|
);
|
||||||
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
||||||
|
|
||||||
@@ -206,7 +229,8 @@ export async function openEditProjectModal(
|
|||||||
field_lead_user_id: userId(fieldLead.select),
|
field_lead_user_id: userId(fieldLead.select),
|
||||||
designer_user_id: userId(designer.select),
|
designer_user_id: userId(designer.select),
|
||||||
logo_asset_id: logo.value(),
|
logo_asset_id: logo.value(),
|
||||||
signature_asset_id: signature.value(),
|
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
|
||||||
|
signature_asset_id: null,
|
||||||
});
|
});
|
||||||
showToast(L("B01_Dashboard_Saved"), "success");
|
showToast(L("B01_Dashboard_Saved"), "success");
|
||||||
});
|
});
|
||||||
@@ -244,6 +268,33 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar
|
|||||||
const phoneVal = (target as DashboardUser).phone || "";
|
const phoneVal = (target as DashboardUser).phone || "";
|
||||||
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal });
|
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal });
|
||||||
|
|
||||||
|
// 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를
|
||||||
|
// 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다.
|
||||||
|
const signatureSlot = document.createElement("div");
|
||||||
|
const companyId = (target as DashboardUser).company_id ?? user.company_id;
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).root,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (user.role === "ADMIN") {
|
if (user.role === "ADMIN") {
|
||||||
// ADMIN은 직책(position) / 부서(department)만 수정 정보 가능
|
// ADMIN은 직책(position) / 부서(department)만 수정 정보 가능
|
||||||
name.input.disabled = true;
|
name.input.disabled = true;
|
||||||
@@ -257,7 +308,7 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar
|
|||||||
|
|
||||||
openModal(
|
openModal(
|
||||||
L("B01_Dashboard_EditUser"),
|
L("B01_Dashboard_EditUser"),
|
||||||
[name.root, position.root, department.root, phone.root],
|
[name.root, position.root, department.root, phone.root, signatureSlot],
|
||||||
async () => {
|
async () => {
|
||||||
await updateDashboardUser(target.id, {
|
await updateDashboardUser(target.id, {
|
||||||
name: name.input.value.trim(),
|
name: name.input.value.trim(),
|
||||||
@@ -306,18 +357,32 @@ export function openCreateCompanyModal(): void {
|
|||||||
});
|
});
|
||||||
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
|
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
|
||||||
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
|
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
|
||||||
|
// 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다.
|
||||||
|
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
|
||||||
|
logo.input.type = "file";
|
||||||
|
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
||||||
|
|
||||||
openModal(
|
openModal(
|
||||||
L("B01_Dashboard_Modal_CreateCompany"),
|
L("B01_Dashboard_Modal_CreateCompany"),
|
||||||
[name.root, number.root, address.root, owner.root],
|
[name.root, number.root, address.root, owner.root, logo.root],
|
||||||
async () => {
|
async () => {
|
||||||
if (!name.input.value.trim() || !number.input.value.trim()) return;
|
if (!name.input.value.trim() || !number.input.value.trim()) return;
|
||||||
await createCompany({
|
const created = await createCompany({
|
||||||
name: name.input.value.trim(),
|
name: name.input.value.trim(),
|
||||||
business_registration_number: number.input.value.trim(),
|
business_registration_number: number.input.value.trim(),
|
||||||
business_address: address.input.value.trim() || null,
|
business_address: address.input.value.trim() || null,
|
||||||
business_owner: owner.input.value.trim() || null,
|
business_owner: owner.input.value.trim() || null,
|
||||||
});
|
});
|
||||||
|
const file = logo.input.files?.[0];
|
||||||
|
if (file && created?.company_id) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("kind", "LOGO");
|
||||||
|
form.append("label", `${name.input.value.trim()} 로고`);
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("company_id", String(created.company_id));
|
||||||
|
const assetId = await createCompanyAsset(form);
|
||||||
|
await setCompanyLogo(assetId, created.company_id);
|
||||||
|
}
|
||||||
showToast(L("B01_Dashboard_Saved"), "success");
|
showToast(L("B01_Dashboard_Saved"), "success");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -361,15 +426,33 @@ export function openFindCompanyModal(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openAddMemberModal(): void {
|
/**
|
||||||
|
* 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다**
|
||||||
|
* (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다.
|
||||||
|
*/
|
||||||
|
// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`).
|
||||||
|
// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것.
|
||||||
|
export function openAddMemberModal(onCreated?: (member: Member) => void): void {
|
||||||
const email = createInputField({
|
const email = createInputField({
|
||||||
label: L("B01_Dashboard_Field_MemberEmail"),
|
label: L("B01_Dashboard_Field_MemberEmail"),
|
||||||
type: "email",
|
type: "email",
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => {
|
const name = createInputField({ label: L("B01_Dashboard_Table_Name") });
|
||||||
if (!email.input.value.trim()) return;
|
const position = createInputField({ label: L("B01_Dashboard_Table_Position") });
|
||||||
await addCompanyMember(email.input.value.trim());
|
const department = createInputField({ label: L("B01_Dashboard_Table_Department") });
|
||||||
showToast(L("B01_Dashboard_Saved"), "success");
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
createButton({ label: "+", onClick: () => openAddMemberModal() }),
|
||||||
]),
|
]),
|
||||||
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true),
|
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true),
|
||||||
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [
|
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [
|
||||||
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
|
||||||
]),
|
]),
|
||||||
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
|
||||||
|
|||||||
@@ -241,6 +241,18 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 서명 그리기 판 — touch-action 을 끊어야 끌기가 스크롤로 새지 않는다 */
|
||||||
|
.b01-dashboard__sign-pad {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 3 / 1;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-cards);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
.b01-dashboard__check {
|
.b01-dashboard__check {
|
||||||
display: block;
|
display: block;
|
||||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
margin: var(--spacing-8) 0 var(--spacing-16);
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ async def create_project(
|
|||||||
project_year: int,
|
project_year: int,
|
||||||
estimated_length_m: float | None,
|
estimated_length_m: float | None,
|
||||||
memo: str | None,
|
memo: str | None,
|
||||||
|
title_block: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다."""
|
"""신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다."""
|
||||||
|
|
||||||
project_id = str(uuid4())
|
project_id = str(uuid4())
|
||||||
|
fields = title_block or {}
|
||||||
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
|
storage_path, project_root = _build_project_storage(company_id, user_id, project_id)
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
@@ -67,9 +69,12 @@ async def create_project(
|
|||||||
INSERT INTO projects (
|
INSERT INTO projects (
|
||||||
id, user_id, company_id, name, region, road_type,
|
id, user_id, company_id, name, region, road_type,
|
||||||
project_year, estimated_length_m, memo, status,
|
project_year, estimated_length_m, memo, status,
|
||||||
crs_epsg, storage_path, created_at, updated_at
|
crs_epsg, storage_path, created_at, updated_at,
|
||||||
|
client_org, project_number, work_amount, design_date,
|
||||||
|
pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
project_id,
|
project_id,
|
||||||
@@ -84,6 +89,14 @@ async def create_project(
|
|||||||
storage_path,
|
storage_path,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
fields.get("client_org"),
|
||||||
|
fields.get("project_number"),
|
||||||
|
fields.get("work_amount"),
|
||||||
|
fields.get("design_date"),
|
||||||
|
fields.get("pm_user_id"),
|
||||||
|
fields.get("field_lead_user_id"),
|
||||||
|
fields.get("designer_user_id"),
|
||||||
|
fields.get("logo_asset_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# 워크플로우 단계별 상태 초기화 시드
|
# 워크플로우 단계별 상태 초기화 시드
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs
|
||||||
from common_util.common_util_auth import require_company
|
from common_util.common_util_auth import require_company
|
||||||
|
|
||||||
from .B02_ProjRegister_Repository import create_project
|
from .B02_ProjRegister_Repository import create_project
|
||||||
@@ -23,6 +24,21 @@ async def post_project(
|
|||||||
if company_id is None:
|
if company_id is None:
|
||||||
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
|
||||||
|
|
||||||
|
title_block = payload.model_dump(
|
||||||
|
include={
|
||||||
|
"client_org",
|
||||||
|
"project_number",
|
||||||
|
"work_amount",
|
||||||
|
"design_date",
|
||||||
|
"pm_user_id",
|
||||||
|
"field_lead_user_id",
|
||||||
|
"designer_user_id",
|
||||||
|
"logo_asset_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙).
|
||||||
|
await check_project_refs(int(company_id), title_block)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await create_project(
|
result = await create_project(
|
||||||
user_id=int(session["user_id"]),
|
user_id=int(session["user_id"]),
|
||||||
@@ -33,6 +49,7 @@ async def post_project(
|
|||||||
project_year=payload.project_year,
|
project_year=payload.project_year,
|
||||||
estimated_length_m=payload.estimated_length_m,
|
estimated_length_m=payload.estimated_length_m,
|
||||||
memo=payload.memo.strip() if payload.memo else None,
|
memo=payload.memo.strip() if payload.memo else None,
|
||||||
|
title_block=title_block,
|
||||||
)
|
)
|
||||||
return CreateProjectResponse(**result)
|
return CreateProjectResponse(**result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""B02_ProjRegister 요청/응답 스키마."""
|
"""B02_ProjRegister 요청/응답 스키마."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -8,10 +10,22 @@ class CreateProjectRequest(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
region: str = Field(..., min_length=1, max_length=100)
|
region: str = Field(..., min_length=1, max_length=100)
|
||||||
road_type: str = Field(..., pattern="^(main|branch|fire|stream)$")
|
# 화면 선택지와 같은 3종 — 간선·산불진화·작업임도 (지선임도 폐지, 계류보전은 사방사업).
|
||||||
|
# 옛 값(branch·stream)을 받고 work 를 막고 있어 「작업임도」 등록이 422 로 떨어졌다.
|
||||||
|
road_type: str = Field(..., pattern="^(main|fire|work)$")
|
||||||
project_year: int = Field(..., ge=2000, le=2100)
|
project_year: int = Field(..., ge=2000, le=2100)
|
||||||
estimated_length_m: float | None = Field(default=None, ge=0)
|
estimated_length_m: float | None = Field(default=None, ge=0)
|
||||||
memo: str | None = Field(default=None, max_length=1000)
|
memo: str | None = Field(default=None, max_length=1000)
|
||||||
|
# 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시).
|
||||||
|
# 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다.
|
||||||
|
client_org: str | None = Field(default=None, max_length=255)
|
||||||
|
project_number: str | None = Field(default=None, max_length=100)
|
||||||
|
work_amount: str | None = Field(default=None, max_length=100)
|
||||||
|
design_date: date | None = None
|
||||||
|
pm_user_id: int | None = Field(default=None, gt=0)
|
||||||
|
field_lead_user_id: int | None = Field(default=None, gt=0)
|
||||||
|
designer_user_id: int | None = Field(default=None, gt=0)
|
||||||
|
logo_asset_id: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class CreateProjectResponse(BaseModel):
|
class CreateProjectResponse(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { isBlank } from "@util/common_util_validate";
|
import { isBlank } from "@util/common_util_validate";
|
||||||
|
import {
|
||||||
|
fetchCompanyAssets,
|
||||||
|
fetchCompanyMembers,
|
||||||
|
fetchDashboardMe,
|
||||||
|
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 { navigateTo } from "../A00_Common/router";
|
||||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||||
import "./B02_ProjRegister_UI_Style.css";
|
import "./B02_ProjRegister_UI_Style.css";
|
||||||
@@ -88,6 +96,84 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
|||||||
type: "text",
|
type: "text",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다
|
||||||
|
// (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다.
|
||||||
|
const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" });
|
||||||
|
const projectNumberField = createInputField({
|
||||||
|
label: "연도·기번 (표지)",
|
||||||
|
placeholder: "예: 2026년 간선임도(기번3-울진.대흥)",
|
||||||
|
});
|
||||||
|
const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" });
|
||||||
|
const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" });
|
||||||
|
|
||||||
|
const NEW_MEMBER = "__new__";
|
||||||
|
const person = (label: string) =>
|
||||||
|
createSelectField({ label, options: [{ value: "", text: "(미지정)" }] });
|
||||||
|
const pmField = person("과업책임자 (도면 표제란)");
|
||||||
|
const fieldLeadField = person("분야별책임자 (도면 표제란)");
|
||||||
|
const designerField = person("설계자 (도면 표제란)");
|
||||||
|
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
|
||||||
|
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
|
||||||
|
const logoSlot = document.createElement("div");
|
||||||
|
let logoValue: () => number | null = () => null;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const [me, company] = await Promise.all([
|
||||||
|
fetchDashboardMe(),
|
||||||
|
fetchUserCompany().catch(() => null),
|
||||||
|
]);
|
||||||
|
const [members, assets] = await Promise.all([
|
||||||
|
fetchCompanyMembers().catch(() => []),
|
||||||
|
fetchCompanyAssets().catch(() => []),
|
||||||
|
]);
|
||||||
|
const memberText = (member: (typeof members)[number]) =>
|
||||||
|
member.position ? `${member.name} (${member.position})` : member.name;
|
||||||
|
const addOption = (select: HTMLSelectElement, value: string, text: string) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = text;
|
||||||
|
select.append(option);
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (company) {
|
||||||
|
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
|
||||||
|
const logo = createAssetField(
|
||||||
|
"회사 로고 (도면 표제란)",
|
||||||
|
"LOGO",
|
||||||
|
assets,
|
||||||
|
company.logo_asset_id ?? null,
|
||||||
|
company.id,
|
||||||
|
me,
|
||||||
|
);
|
||||||
|
logoValue = logo.value;
|
||||||
|
logoSlot.append(logo.root);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const submitBtn = createButton({
|
const submitBtn = createButton({
|
||||||
label: L("B02_Proj_Submit"),
|
label: L("B02_Proj_Submit"),
|
||||||
variant: "filled",
|
variant: "filled",
|
||||||
@@ -95,6 +181,9 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
|||||||
});
|
});
|
||||||
submitBtn.classList.add("b02-proj__submit");
|
submitBtn.classList.add("b02-proj__submit");
|
||||||
|
|
||||||
|
const idOrNull = (select: HTMLSelectElement): number | null =>
|
||||||
|
select.value && select.value !== NEW_MEMBER ? Number(select.value) : null;
|
||||||
|
|
||||||
async function onB02_Proj_Submit_Click(): Promise<void> {
|
async function onB02_Proj_Submit_Click(): Promise<void> {
|
||||||
nameField.setError();
|
nameField.setError();
|
||||||
regionField.setError();
|
regionField.setError();
|
||||||
@@ -138,6 +227,14 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
|||||||
project_year: projectYear,
|
project_year: projectYear,
|
||||||
estimated_length_m: estimatedLength,
|
estimated_length_m: estimatedLength,
|
||||||
memo: memoField.input.value.trim() || null,
|
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,
|
||||||
|
design_date: designDateField.input.value || null,
|
||||||
|
pm_user_id: idOrNull(pmField.select),
|
||||||
|
field_lead_user_id: idOrNull(fieldLeadField.select),
|
||||||
|
designer_user_id: idOrNull(designerField.select),
|
||||||
|
logo_asset_id: logoValue(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +270,14 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
|||||||
yearField.root,
|
yearField.root,
|
||||||
lengthField.root,
|
lengthField.root,
|
||||||
memoField.root,
|
memoField.root,
|
||||||
|
clientOrgField.root,
|
||||||
|
projectNumberField.root,
|
||||||
|
workAmountField.root,
|
||||||
|
designDateField.root,
|
||||||
|
pmField.root,
|
||||||
|
fieldLeadField.root,
|
||||||
|
designerField.root,
|
||||||
|
logoSlot,
|
||||||
);
|
);
|
||||||
|
|
||||||
const card = createCard({ body: [grid, submitBtn], raised: true });
|
const card = createCard({ body: [grid, submitBtn], raised: true });
|
||||||
|
|||||||
@@ -120,9 +120,15 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|||||||
|
|
||||||
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
|
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
|
||||||
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
|
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
|
||||||
로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 —
|
**이름이 들어가는 자리는 서명도 받는다**(2026-09-02 사용자 확정) — 세 사람 모두
|
||||||
안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` ·
|
자기 계정의 `SIGNATURE` 자산을 따라가고, 안 올린 사람 자리는 그림째 빠진다.
|
||||||
`users.signature_path` 는 더 읽지 않는다.
|
로고·서명은 회사 공유 자산(`company_assets`, 013)에서 오되 **원천이 다르다**
|
||||||
|
(2026-09-02 사용자 확정, B01 인계). 서명은 프로젝트가 아니라 **사람 계정**에 붙으므로
|
||||||
|
설계자(`designer_user_id`, 없으면 소유자) 계정의 `SIGNATURE` 자산을 따라간다. 로고는
|
||||||
|
프로젝트가 고른 것이 우선이고, 안 골랐으면 **회사 등록 단계에서 받은 회사 로고**
|
||||||
|
(`companies.logo_asset_id`, 014)로 떨어진다. 둘 다 없거나 자산이 지워졌으면 그림째
|
||||||
|
빠진다. `projects.signature_asset_id` 는 컬럼만 남기고 더 읽지 않는다(B01 이 항상
|
||||||
|
`null` 로 저장). 011 의 `companies.logo_path` · `users.signature_path` 도 안 읽는다.
|
||||||
|
|
||||||
아직 못 채우는 자리와 이유:
|
아직 못 채우는 자리와 이유:
|
||||||
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
|
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
|
||||||
@@ -137,7 +143,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|||||||
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
|
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
|
||||||
p.design_date, c.name, logo.file_path,
|
p.design_date, c.name, logo.file_path,
|
||||||
COALESCE(designer.name, owner.name), sig.file_path,
|
COALESCE(designer.name, owner.name), sig.file_path,
|
||||||
pm.name, lead.name
|
pm.name, lead.name, pm_sig.file_path, lead_sig.file_path
|
||||||
FROM projects p
|
FROM projects p
|
||||||
LEFT JOIN companies c ON c.id = p.company_id
|
LEFT JOIN companies c ON c.id = p.company_id
|
||||||
LEFT JOIN users owner ON owner.id = p.user_id
|
LEFT JOIN users owner ON owner.id = p.user_id
|
||||||
@@ -145,9 +151,38 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|||||||
LEFT JOIN users pm ON pm.id = p.pm_user_id
|
LEFT JOIN users pm ON pm.id = p.pm_user_id
|
||||||
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
|
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
|
||||||
LEFT JOIN company_assets logo
|
LEFT JOIN company_assets logo
|
||||||
ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL
|
ON logo.id = COALESCE(p.logo_asset_id, c.logo_asset_id)
|
||||||
|
AND logo.deleted_at IS NULL
|
||||||
LEFT JOIN company_assets sig
|
LEFT JOIN company_assets sig
|
||||||
ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL
|
ON sig.id = (
|
||||||
|
SELECT s.id
|
||||||
|
FROM company_assets s
|
||||||
|
WHERE s.user_id = COALESCE(p.designer_user_id, p.user_id)
|
||||||
|
AND s.kind = 'SIGNATURE'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
ORDER BY s.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
LEFT JOIN company_assets pm_sig
|
||||||
|
ON pm_sig.id = (
|
||||||
|
SELECT s.id
|
||||||
|
FROM company_assets s
|
||||||
|
WHERE s.user_id = p.pm_user_id
|
||||||
|
AND s.kind = 'SIGNATURE'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
ORDER BY s.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
LEFT JOIN company_assets lead_sig
|
||||||
|
ON lead_sig.id = (
|
||||||
|
SELECT s.id
|
||||||
|
FROM company_assets s
|
||||||
|
WHERE s.user_id = p.field_lead_user_id
|
||||||
|
AND s.kind = 'SIGNATURE'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
ORDER BY s.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
WHERE p.id = %s AND p.deleted_at IS NULL
|
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||||
""",
|
""",
|
||||||
(str(project_id),),
|
(str(project_id),),
|
||||||
@@ -168,6 +203,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|||||||
signature_path,
|
signature_path,
|
||||||
pm,
|
pm,
|
||||||
lead,
|
lead,
|
||||||
|
pm_signature_path,
|
||||||
|
lead_signature_path,
|
||||||
) = row
|
) = row
|
||||||
fields = {
|
fields = {
|
||||||
"공사명": name,
|
"공사명": name,
|
||||||
@@ -183,6 +220,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
|||||||
"분야별책임자": lead,
|
"분야별책임자": lead,
|
||||||
"회사로고": _asset_data_url(logo_path),
|
"회사로고": _asset_data_url(logo_path),
|
||||||
"설계자서명": _asset_data_url(signature_path),
|
"설계자서명": _asset_data_url(signature_path),
|
||||||
|
"과업책임자서명": _asset_data_url(pm_signature_path),
|
||||||
|
"분야별책임자서명": _asset_data_url(lead_signature_path),
|
||||||
}
|
}
|
||||||
return {key: str(value) for key, value in fields.items() if value}
|
return {key: str(value) for key, value in fields.items() if value}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
export interface FrameTemplateEditor {
|
export interface FrameTemplateEditor {
|
||||||
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
|
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
|
||||||
button: HTMLButtonElement;
|
button: HTMLButtonElement;
|
||||||
/** 편집 중임을 알리는 CAD 화면 상단 띠 (평소엔 숨김). */
|
/** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */
|
||||||
banner: HTMLElement;
|
banner: HTMLElement;
|
||||||
/** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */
|
/** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */
|
||||||
isEditing: () => boolean;
|
isEditing: () => boolean;
|
||||||
@@ -66,7 +66,10 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
|||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
onClick: () => leave(),
|
onClick: () => leave(),
|
||||||
});
|
});
|
||||||
banner.append(finishButton, resetButton, cancelButton);
|
const bannerButtons = document.createElement("div");
|
||||||
|
bannerButtons.className = "b07-frame-edit__buttons";
|
||||||
|
bannerButtons.append(finishButton, resetButton, cancelButton);
|
||||||
|
banner.append(bannerButtons);
|
||||||
|
|
||||||
const button = createButton({
|
const button = createButton({
|
||||||
label: "도각 편집",
|
label: "도각 편집",
|
||||||
|
|||||||
@@ -601,7 +601,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|||||||
},
|
},
|
||||||
onSaved: () => drawingCache.clear(),
|
onSaved: () => drawingCache.clear(),
|
||||||
});
|
});
|
||||||
cadHost.prepend(frameEditor.banner);
|
|
||||||
|
|
||||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||||
@@ -668,7 +667,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|||||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||||
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
// 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통).
|
||||||
confirmActions.className = "b07-drawing-actions ui-sidebar-actions";
|
confirmActions.className = "b07-drawing-actions ui-sidebar-actions";
|
||||||
confirmActions.append(frameEditor.button, confirmButton);
|
// 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정]
|
||||||
|
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
|
||||||
|
const confirmButtonRow = document.createElement("div");
|
||||||
|
confirmButtonRow.className = "b07-drawing-actions__row";
|
||||||
|
confirmButtonRow.append(frameEditor.button, confirmButton);
|
||||||
|
confirmActions.append(frameEditor.banner, confirmButtonRow);
|
||||||
|
|
||||||
drawingPanel.append(infoPanelHost, confirmActions);
|
drawingPanel.append(infoPanelHost, confirmActions);
|
||||||
|
|
||||||
|
|||||||
@@ -126,11 +126,25 @@
|
|||||||
|
|
||||||
/* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합).
|
/* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합).
|
||||||
여기서는 B07 고유 여백만 남긴다. */
|
여기서는 B07 고유 여백만 남긴다. */
|
||||||
|
/* 액션 칸은 세로 두 줄 — 1행 도각 편집 띠, 2행 [도각 편집]·[현재 도면 확정].
|
||||||
|
공용 .ui-sidebar-actions는 가로 한 줄이라 방향과 늘어남을 여기서 되돌린다. */
|
||||||
.b07-drawing-actions {
|
.b07-drawing-actions {
|
||||||
|
flex-direction: column;
|
||||||
padding-top: var(--spacing-12);
|
padding-top: var(--spacing-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-drawing-actions > button {
|
.b07-drawing-actions > * {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-drawing-actions__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-drawing-actions__row > button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,29 +285,36 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 도각 편집 모드 띠 — CAD 위에 겹쳐 편집 중임을 알리고 [완료]·[취소]를 준다. */
|
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
|
||||||
|
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
|
||||||
.b07-frame-edit {
|
.b07-frame-edit {
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
top: 8px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
padding: 6px 10px;
|
padding: var(--spacing-8);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-cards);
|
border-radius: var(--radius-cards);
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
box-shadow: 0 6px 18px rgb(0 0 0 / 25%);
|
}
|
||||||
|
|
||||||
|
.b07-frame-edit[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-frame-edit__label {
|
.b07-frame-edit__label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b07-frame-edit > button {
|
.b07-frame-edit__buttons {
|
||||||
padding: 4px 12px;
|
display: flex;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-frame-edit__buttons > button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */
|
/** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */
|
||||||
|
import { Point } from '@flatten-js/core';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { CadCommand } from './command.types';
|
import type { CadCommand } from './command.types';
|
||||||
import { bumpSceneVersion } from '../helpers/scene-version';
|
import { bumpSceneVersion } from '../helpers/scene-version';
|
||||||
@@ -8,8 +9,23 @@ import { selectToolStateMachine } from '../tools/select-tool';
|
|||||||
|
|
||||||
const zoomBy = (factor: number): string => {
|
const zoomBy = (factor: number): string => {
|
||||||
const controller = getScreenCanvasDrawController();
|
const controller = getScreenCanvasDrawController();
|
||||||
controller.setScreenScale(Math.max(0.01, controller.getScreenScale() * factor));
|
const oldScale = controller.getScreenScale();
|
||||||
return `줌 ${Math.round(controller.getScreenScale() * 100)}%`;
|
const newScale = Math.max(0.01, oldScale * factor);
|
||||||
|
// 배율만 바꾸면 화면이 월드 원점 쪽으로 늘어나 도면이 화면 밖으로 밀려난다.
|
||||||
|
// 휠 줌이 커서 밑 좌표를 붙잡아 두듯, 버튼 줌은 **화면 중심**의 월드 좌표를
|
||||||
|
// 붙잡아 둔다 (screen = (world - offset) * scale).
|
||||||
|
const canvasSize = controller.getCanvasSize();
|
||||||
|
const offset = controller.getScreenOffset();
|
||||||
|
const worldCenterX = offset.x + canvasSize.x / 2 / oldScale;
|
||||||
|
const worldCenterY = offset.y + canvasSize.y / 2 / oldScale;
|
||||||
|
controller.setScreenScale(newScale);
|
||||||
|
controller.setScreenOffset(
|
||||||
|
new Point(
|
||||||
|
worldCenterX - canvasSize.x / 2 / newScale,
|
||||||
|
worldCenterY - canvasSize.y / 2 / newScale
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return `줌 ${Math.round(newScale * 100)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VIEW_COMMANDS: CadCommand[] = [
|
export const VIEW_COMMANDS: CadCommand[] = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { ViewControls } from './ViewControls';
|
|||||||
export const Toolbar: FC = () => {
|
export const Toolbar: FC = () => {
|
||||||
useCadRefresh();
|
useCadRefresh();
|
||||||
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
const [panelCollapsed, setPanelCollapsed] = useState(false);
|
||||||
const [commandLineVisible, setCommandLineVisible] = useState(true);
|
const [commandLineVisible, setCommandLineVisible] = useState(false);
|
||||||
|
|
||||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||||
|
|
||||||
|
|||||||
@@ -596,6 +596,11 @@ export class ScreenCanvasDrawController implements DrawController {
|
|||||||
angle: number
|
angle: number
|
||||||
): void {
|
): void {
|
||||||
if (this.batching) this.flushBatch();
|
if (this.batching) this.flushBatch();
|
||||||
|
// 못 읽은 그림은 건너뛴다. 'broken' 상태의 그림을 그리려 하면 캔버스가 예외를
|
||||||
|
// 던지고, 그 예외가 렌더 루프를 끊어 **이후 모든 도면이 백지**로 남았다
|
||||||
|
// (2026-09-02 실측: 도각 자리표시자가 404 로 깨진 채 줌 한 번에 화면이 멈췄다).
|
||||||
|
// 자리 하나가 비는 것과 도면 전체가 안 나오는 것은 무게가 다르다.
|
||||||
|
if (imageElement.complete === false || imageElement.naturalWidth === 0) return;
|
||||||
// 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과
|
// 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과
|
||||||
// y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02
|
// y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02
|
||||||
// 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다.
|
// 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다.
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ export class ImageEntity implements Entity {
|
|||||||
private imageElement: HTMLImageElement;
|
private imageElement: HTMLImageElement;
|
||||||
private polygon: Polygon;
|
private polygon: Polygon;
|
||||||
private angle: number;
|
private angle: number;
|
||||||
|
/**
|
||||||
|
* JSON에서 받은 그림 주소 원본. `imageElement.currentSrc`는 브라우저가 절대 URL로
|
||||||
|
* 바꿔 놓아, 도각의 자리표시자(`{{회사로고}}`)가 저장 한 번에
|
||||||
|
* `http://…/b07-cad/%7B%7B회사로고%7D%7D`로 굳는다(2026-09-02 실측 — 회사 도각이
|
||||||
|
* 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다.
|
||||||
|
*/
|
||||||
|
private sourceData: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
layerId: string,
|
layerId: string,
|
||||||
@@ -115,7 +122,9 @@ export class ImageEntity implements Entity {
|
|||||||
public clone(): ImageEntity {
|
public clone(): ImageEntity {
|
||||||
const clonedImage = document.createElement('img');
|
const clonedImage = document.createElement('img');
|
||||||
clonedImage.src = this.imageElement.src;
|
clonedImage.src = this.imageElement.src;
|
||||||
return new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone());
|
const cloned = new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone());
|
||||||
|
cloned.sourceData = this.sourceData;
|
||||||
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO add destroy method to cleanup this.imageElement.src
|
// TODO add destroy method to cleanup this.imageElement.src
|
||||||
@@ -230,7 +239,7 @@ export class ImageEntity implements Entity {
|
|||||||
x: vertex.x,
|
x: vertex.x,
|
||||||
y: vertex.y,
|
y: vertex.y,
|
||||||
})),
|
})),
|
||||||
imageData: this.imageElement.currentSrc,
|
imageData: this.sourceData ?? this.imageElement.currentSrc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -243,7 +252,12 @@ export class ImageEntity implements Entity {
|
|||||||
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y))
|
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y))
|
||||||
);
|
);
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = jsonEntity.shapeData.imageData;
|
// 자리표시자는 주소가 아니다 — 그대로 넣으면 404 요청이 나가고 그림이 'broken'
|
||||||
|
// 상태가 된다. 그 상태의 그림을 그리려 하면 캔버스가 예외를 던져 렌더 루프가
|
||||||
|
// 끊기고 **이후 모든 도면이 백지**로 남았다(2026-09-02 실측). 자리만 남긴다.
|
||||||
|
if (!jsonEntity.shapeData.imageData.includes('{{')) {
|
||||||
|
image.src = jsonEntity.shapeData.imageData;
|
||||||
|
}
|
||||||
const rectangleEntity = new ImageEntity(
|
const rectangleEntity = new ImageEntity(
|
||||||
jsonEntity.layerId || getActiveLayerId(),
|
jsonEntity.layerId || getActiveLayerId(),
|
||||||
image,
|
image,
|
||||||
@@ -253,6 +267,7 @@ export class ImageEntity implements Entity {
|
|||||||
rectangleEntity.lineColor = jsonEntity.lineColor;
|
rectangleEntity.lineColor = jsonEntity.lineColor;
|
||||||
rectangleEntity.lineWidth = jsonEntity.lineWidth;
|
rectangleEntity.lineWidth = jsonEntity.lineWidth;
|
||||||
rectangleEntity.lineDash = jsonEntity.lineDash;
|
rectangleEntity.lineDash = jsonEntity.lineDash;
|
||||||
|
rectangleEntity.sourceData = jsonEntity.shapeData.imageData;
|
||||||
return rectangleEntity;
|
return rectangleEntity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 014_company_logo.sql
|
||||||
|
-- 표제란 사람·서명·로고 재배치 (2026-09-02 사용자 확정)
|
||||||
|
--
|
||||||
|
-- ① 서명은 프로젝트가 아니라 **사람(계정)에 붙는다** — 이름이 들어가는 자리
|
||||||
|
-- (과업책임자·분야별책임자·설계자)의 서명을 그 사람 계정에서 읽는다.
|
||||||
|
-- 표현 수단은 013 의 `company_assets` 그대로다: `kind='SIGNATURE'` + `user_id=<그 사람>`.
|
||||||
|
-- 그래서 **서명 쪽은 새 컬럼이 없다**.
|
||||||
|
-- ② 회사 로고는 **회사 등록 단계**에서 받고 이후 수정·변경한다 — 회사마다 대표 로고 1벌을
|
||||||
|
-- 가리키는 자리가 필요해 이 컬럼을 만든다(`kind='LOGO'` + `user_id IS NULL`).
|
||||||
|
-- ③ 프로젝트가 고른 로고(`projects.logo_asset_id`)는 그대로 두고 **덮어쓰기**로 남긴다 —
|
||||||
|
-- 비어 있으면 회사 로고를 쓴다.
|
||||||
|
--
|
||||||
|
-- `projects.signature_asset_id`(013)는 이제 읽지 않는다. 컬럼은 남겨 둔다
|
||||||
|
-- (4환경 공유 DB 라 지우는 쪽이 위험하다 — 013 과 같은 판단).
|
||||||
|
|
||||||
|
USE aislo_db;
|
||||||
|
|
||||||
|
ALTER TABLE companies
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL
|
||||||
|
COMMENT '회사 대표 로고 (company_assets.id, kind=LOGO)';
|
||||||
@@ -1277,6 +1277,62 @@
|
|||||||
],
|
],
|
||||||
"imageData": "{{설계자서명}}"
|
"imageData": "{{설계자서명}}"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d",
|
||||||
|
"type": "Image",
|
||||||
|
"lineColor": "#f5f7fa",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"layerId": "-00.기본BOX TEXT",
|
||||||
|
"shapeData": {
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"x": 536.89,
|
||||||
|
"y": 17.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 578.89,
|
||||||
|
"y": 17.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 578.89,
|
||||||
|
"y": 25.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 536.89,
|
||||||
|
"y": 25.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"imageData": "{{과업책임자서명}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d404c499-15dc-528f-a3bd-821cefa41961",
|
||||||
|
"type": "Image",
|
||||||
|
"lineColor": "#f5f7fa",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"layerId": "-00.기본BOX TEXT",
|
||||||
|
"shapeData": {
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"x": 587.91,
|
||||||
|
"y": 17.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 629.91,
|
||||||
|
"y": 17.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 629.91,
|
||||||
|
"y": 25.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 587.91,
|
||||||
|
"y": 25.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"imageData": "{{분야별책임자서명}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"layers": [
|
"layers": [
|
||||||
@@ -1293,4 +1349,4 @@
|
|||||||
"isLocked": false
|
"isLocked": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,34 @@
|
|||||||
"fontFamily": "sans-serif"
|
"fontFamily": "sans-serif"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "944f4ed1-a4e0-5ddf-b91f-3847984d2659",
|
||||||
|
"type": "Image",
|
||||||
|
"lineColor": "#f5f7fa",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"layerId": "-00.표지",
|
||||||
|
"shapeData": {
|
||||||
|
"points": [
|
||||||
|
{
|
||||||
|
"x": 57.4,
|
||||||
|
"y": 64.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 137.4,
|
||||||
|
"y": 64.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 137.4,
|
||||||
|
"y": 104.06
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 57.4,
|
||||||
|
"y": 104.06
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"imageData": "{{회사로고}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"layers": [
|
"layers": [
|
||||||
@@ -348,4 +376,4 @@
|
|||||||
"isLocked": false
|
"isLocked": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user