feat(B01,B07): 담당자 회사 구성원 지정 + 로고·서명 회사 공유 자산 모달

- B01 프로젝트 수정: 과업책임자·분야별책임자·설계자를 회사 구성원 목록(select)에서
  지정. 회사 로고·설계자 서명 칸은 누르면 등록 목록 모달(선택·삭제·신규 추가 업로드).
- 백엔드: `company_assets`(013) CRUD 라우트 신설 — 목록·업로드(Form+File, 2MB·확장자
  검사)·수정·소프트 삭제·파일 서빙. `_scope_company` 로 SYSTEM_ADMIN 만 타 회사 지정,
  `_check_project_refs` 로 담당자·자산이 같은 회사·같은 종류인지 검사(400).
- projects 조회·수정에 pm/field_lead/designer/logo_asset/signature_asset 5칸 합류.
  중복 3곳을 `_project_rows` 로 묶어 700줄 제한 유지.
- B07 표제란: 로고·서명 출처를 `companies.logo_path`/`users.signature_path` 에서
  프로젝트가 고른 `company_assets` 로 전환(삭제된 자산은 그림째 제외).
- 검증: pytest 143 통과(신규 4), tsc·ruff·prettier 통과. 공용 브라우저 사용자 경로
  (업로드→선택→저장→재열기) 및 종단도 API 표제란 글자 4건·그림 2건 수치 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 12:02:49 +09:00
co-authored by Claude Opus 5
parent 313a7dde43
commit 00d30587b3
9 changed files with 672 additions and 70 deletions
+55 -2
View File
@@ -48,6 +48,12 @@ export interface ProjectItem {
project_number?: string | null;
work_amount?: string | null;
design_date?: string | null;
/** 담당자는 회사 구성원(users.id), 로고·서명은 회사 공유 자산(company_assets.id) */
pm_user_id?: number | null;
field_lead_user_id?: number | null;
designer_user_id?: number | null;
logo_asset_id?: number | null;
signature_asset_id?: number | null;
owner_name?: string | null;
workflow_stage: number;
progress_percent: number;
@@ -76,6 +82,16 @@ export interface Member {
status: string;
}
/** 회사 안에서 공유하는 도면 자산(로고·서명). user_id 가 없으면 회사 공용. */
export interface CompanyAsset {
id: number;
company_id: number;
kind: "LOGO" | "SIGNATURE";
label: string;
user_id?: number | null;
user_name?: string | null;
}
export interface JoinRequest {
id: number;
user_id: number;
@@ -141,6 +157,11 @@ export interface UpdateProjectRequest {
project_number?: string | null;
work_amount?: string | null;
design_date?: string | null;
pm_user_id?: number | null;
field_lead_user_id?: number | null;
designer_user_id?: number | null;
logo_asset_id?: number | null;
signature_asset_id?: number | null;
}
export interface AdminUpdateUserRequest extends UpdateUserRequest {
@@ -226,11 +247,43 @@ export function joinCompany(companyId: number): Promise<unknown> {
});
}
export async function fetchCompanyMembers(): Promise<Member[]> {
const data = await request<{ members: Member[] }>("/dashboard/admin/members");
const companyQuery = (companyId?: number | null): string =>
companyId ? `?company_id=${encodeURIComponent(companyId)}` : "";
/** companyId 는 시스템관리자가 남의 회사 프로젝트를 고칠 때만 넘긴다. */
export async function fetchCompanyMembers(companyId?: number | null): Promise<Member[]> {
const data = await request<{ members: Member[] }>(
`/dashboard/admin/members${companyQuery(companyId)}`,
);
return data.members;
}
export async function fetchCompanyAssets(companyId?: number | null): Promise<CompanyAsset[]> {
const data = await request<{ assets: CompanyAsset[] }>(
`/dashboard/company/assets${companyQuery(companyId)}`,
);
return data.assets;
}
/** multipart 업로드라 `request()`의 JSON 헤더를 쓰지 않는다. */
export async function createCompanyAsset(form: FormData): Promise<number> {
const response = await fetch(`${API_BASE_URL}/dashboard/company/assets`, {
method: "POST",
credentials: "include",
body: form,
});
const data = (await response.json()) as { detail?: string; asset_id: number };
if (!response.ok) throw new Error(data.detail ?? "Request failed");
return data.asset_id;
}
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
}
export const companyAssetFileUrl = (assetId: number): string =>
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
export function addCompanyMember(email: string): Promise<unknown> {
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
}
+30 -30
View File
@@ -45,6 +45,16 @@ def _project_row(row: dict[str, Any]) -> dict[str, Any]:
return {**row, "workflow_stage": stage, "progress_percent": progress}
async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows])
result = []
for r in rows:
p_row = _project_row(r)
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
result.append(p_row)
return result
async def get_dashboard_me(user_id: int) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
@@ -149,20 +159,14 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
await cursor.execute(
"""SELECT id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status, updated_at, created_at,
client_org, project_number, work_amount, design_date
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
FROM projects WHERE user_id = %s AND deleted_at IS NULL
ORDER BY updated_at DESC, created_at DESC""",
(user_id,),
)
rows = await cursor.fetchall()
pids = [r["id"] for r in rows]
states = await get_workflow_states_for_projects(cursor, pids)
result = []
for r in rows:
p_row = _project_row(r)
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
result.append(p_row)
return result
return await _project_rows(cursor, await cursor.fetchall())
async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
@@ -172,21 +176,15 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.company_id = %s AND p.deleted_at IS NULL
ORDER BY p.updated_at DESC, p.created_at DESC""",
(company_id,),
)
rows = await cursor.fetchall()
pids = [r["id"] for r in rows]
states = await get_workflow_states_for_projects(cursor, pids)
result = []
for r in rows:
p_row = _project_row(r)
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
result.append(p_row)
return result
return await _project_rows(cursor, await cursor.fetchall())
async def list_all_projects() -> list[dict[str, Any]]:
@@ -196,20 +194,14 @@ async def list_all_projects() -> list[dict[str, Any]]:
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
p.client_org, p.project_number, p.work_amount, p.design_date,
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
p.logo_asset_id, p.signature_asset_id,
u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.deleted_at IS NULL
ORDER BY p.updated_at DESC, p.created_at DESC"""
)
rows = await cursor.fetchall()
pids = [r["id"] for r in rows]
states = await get_workflow_states_for_projects(cursor, pids)
result = []
for r in rows:
p_row = _project_row(r)
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
result.append(p_row)
return result
return await _project_rows(cursor, await cursor.fetchall())
async def get_project(project_id: str) -> dict[str, Any] | None:
@@ -218,7 +210,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
await cursor.execute(
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
estimated_length_m, memo, status,
client_org, project_number, work_amount, design_date
client_org, project_number, work_amount, design_date,
pm_user_id, field_lead_user_id, designer_user_id,
logo_asset_id, signature_asset_id
FROM projects WHERE id = %s AND deleted_at IS NULL""",
(project_id,),
)
@@ -234,7 +228,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
SET name = %s, region = %s, road_type = %s, project_year = %s,
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
client_org = %s, project_number = %s, work_amount = %s,
design_date = %s
design_date = %s, pm_user_id = %s, field_lead_user_id = %s,
designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s
WHERE id = %s AND deleted_at IS NULL""",
(
data["name"],
@@ -248,6 +243,11 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
data.get("project_number"),
data.get("work_amount"),
data.get("design_date"),
data.get("pm_user_id"),
data.get("field_lead_user_id"),
data.get("designer_user_id"),
data.get("logo_asset_id"),
data.get("signature_asset_id"),
project_id,
),
)
@@ -0,0 +1,94 @@
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) 저장소 — `company_assets` (013).
그림은 `storage/{회사}/assets/` 에 파일로 두고 표에는 경로만 담는다. 자산은 사용자 계정에
물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다 — `user_id` NULL 이 공용.
"""
from __future__ import annotations
import os
import uuid
from typing import Any
import aiomysql
from config.config_db import get_db_pool
from config.config_system import STORAGE_BASE_DIR
ASSET_KINDS = ("LOGO", "SIGNATURE")
ASSET_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
ASSET_MAX_BYTES = 2 * 1024 * 1024
async def list_company_assets(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 a.id, a.company_id, a.kind, a.label, a.file_path, a.user_id,
u.name AS user_name, a.created_at
FROM company_assets a LEFT JOIN users u ON u.id = a.user_id
WHERE a.company_id = %s AND a.deleted_at IS NULL
ORDER BY a.kind, a.label, a.id""",
(company_id,),
)
return list(await cursor.fetchall())
async def get_company_asset(asset_id: int) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, company_id, kind, label, file_path, user_id
FROM company_assets WHERE id = %s AND deleted_at IS NULL""",
(asset_id,),
)
return await cursor.fetchone()
def write_company_asset_file(company_id: int, kind: str, suffix: str, blob: bytes) -> str:
"""그림을 `storage/{회사}/assets/` 에 쓰고 storage 기준 상대 경로를 돌려준다."""
folder = os.path.join(STORAGE_BASE_DIR, str(company_id), "assets")
os.makedirs(folder, exist_ok=True)
filename = f"{kind.lower()}_{uuid.uuid4().hex}{suffix}"
with open(os.path.join(folder, filename), "wb") as file:
file.write(blob)
return f"storage/{company_id}/assets/{filename}"
async def create_company_asset(
company_id: int, kind: str, label: str, file_path: str, user_id: int | None, actor_id: int
) -> int:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO company_assets (company_id, kind, label, file_path, user_id, created_by)
VALUES (%s, %s, %s, %s, %s, %s)""",
(company_id, kind, label, file_path, user_id, actor_id),
)
await connection.commit()
return int(cursor.lastrowid)
async def update_company_asset(asset_id: int, label: str, user_id: int | None) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE company_assets SET label = %s, user_id = %s
WHERE id = %s AND deleted_at IS NULL""",
(label, user_id, asset_id),
)
await connection.commit()
return cursor.rowcount > 0
async def delete_company_asset(asset_id: int) -> bool:
"""소프트 삭제 — 프로젝트가 물고 있던 참조는 도면 읽을 때 LEFT JOIN 으로 빈칸이 된다."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE company_assets SET deleted_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL""",
(asset_id,),
)
await connection.commit()
return cursor.rowcount > 0
+136 -4
View File
@@ -1,11 +1,14 @@
"""B01_Dashboard 역할별 대시보드 API."""
import mimetypes
import os
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
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_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 .B01_Dashboard_Repository import (
@@ -35,6 +38,17 @@ from .B01_Dashboard_Repository import (
update_project,
update_user_profile,
)
from .B01_Dashboard_Repository_Assets import (
ASSET_EXTENSIONS,
ASSET_KINDS,
ASSET_MAX_BYTES,
create_company_asset,
delete_company_asset,
get_company_asset,
list_company_assets,
update_company_asset,
write_company_asset_file,
)
from .B01_Dashboard_Schema import (
AddMemberRequest,
AdminUpdateUserRequest,
@@ -43,6 +57,7 @@ from .B01_Dashboard_Schema import (
CreateCompanyRequest,
JoinCompanyRequest,
ProcessJoinRequest,
UpdateCompanyAssetRequest,
UpdateProjectRequest,
UpdateUserRequest,
)
@@ -69,6 +84,43 @@ def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
"""회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다."""
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
return int(company_id)
own = _require_company_id(session)
if company_id is not None and int(company_id) != own:
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
return own
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
asset = await get_company_asset(asset_id)
if not asset:
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
_scope_company(session, int(asset["company_id"]))
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:
if session["role"] == "SYSTEM_ADMIN":
return True
@@ -142,10 +194,14 @@ async def user_company_join(
@router.get("/admin/members")
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
async def admin_members(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
return {
"status": "success",
"members": await list_company_members(_require_company_id(session)),
"members": await list_company_members(_scope_company(session, company_id)),
}
@@ -211,7 +267,9 @@ async def dashboard_update_project(
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if not _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
if not await update_project(project_id, payload.model_dump(), int(session["user_id"])):
data = payload.model_dump()
await _check_project_refs(project, data)
if not await update_project(project_id, data, int(session["user_id"])):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@@ -352,3 +410,77 @@ async def system_resources(
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "projects": await list_all_projects()}
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
@router.get("/company/assets")
async def company_assets(
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(verify_session),
):
return {
"status": "success",
"assets": await list_company_assets(_scope_company(session, company_id)),
}
@router.post("/company/assets")
async def company_add_asset(
kind: str = Form(...),
label: str = Form(...),
user_id: int | None = Form(None, gt=0),
company_id: int | None = Form(None, gt=0),
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
):
kind = kind.upper()
if kind not in ASSET_KINDS:
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
label = label.strip()[:100]
if not label:
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
suffix = os.path.splitext(file.filename or "")[1].lower()
if suffix not in ASSET_EXTENSIONS:
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
blob = await file.read()
if not blob or len(blob) > ASSET_MAX_BYTES:
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
scoped = _scope_company(session, company_id)
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
path = write_company_asset_file(scoped, kind, suffix, blob)
asset_id = await create_company_asset(
scoped, kind, label, path, user_id, int(session["user_id"])
)
return {"status": "success", "asset_id": asset_id}
@router.put("/company/assets/{asset_id}")
async def company_update_asset(
asset_id: int,
payload: UpdateCompanyAssetRequest,
session: dict[str, Any] = Depends(verify_session),
):
await _company_asset(session, asset_id)
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
return {"status": "success"}
@router.delete("/company/assets/{asset_id}")
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
await _company_asset(session, asset_id)
await delete_company_asset(asset_id)
return {"status": "success"}
@router.get("/company/assets/{asset_id}/file")
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
asset = await _company_asset(session, asset_id)
blob = read_stored_asset(asset["file_path"])
if blob is None:
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
return Response(content=blob, media_type=media_type)
+13
View File
@@ -54,9 +54,22 @@ class UpdateProjectRequest(BaseModel):
work_amount: str | None = Field(default=None, max_length=100)
# 설계일자는 확정일 자동이 아니라 사용자 지정이다 (2026-09-02 사용자 확정).
design_date: date | None = None
# 담당자는 회사 구성원(users) 중에서, 로고·서명은 회사 공유 자산(company_assets) 중에서
# 고른다 — 같은 회사 것인지는 라우터가 확인한다.
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)
signature_asset_id: int | None = Field(default=None, gt=0)
class AdminUpdateUserRequest(UpdateUserRequest):
status: str | None = Field(
default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$"
)
class UpdateCompanyAssetRequest(BaseModel):
label: str = Field(min_length=1, max_length=100)
# 사용자 계정에 물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다.
user_id: int | None = Field(default=None, gt=0)
@@ -0,0 +1,184 @@
/**
* (·) (2026-09-02 ).
*
* · ,
* . ( )
* ( ) .
*/
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import {
companyAssetFileUrl,
createCompanyAsset,
deleteCompanyAsset,
fetchCompanyAssets,
type CompanyAsset,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
export interface AssetFieldHandle {
root: HTMLDivElement;
value: () => number | null;
}
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
export function createAssetField(
label: string,
kind: CompanyAsset["kind"],
assets: CompanyAsset[],
initialId: number | null | undefined,
companyId: number | null | undefined,
user: DashboardUser,
): AssetFieldHandle {
let list = assets.filter((asset) => asset.kind === kind);
let selected = list.find((asset) => asset.id === initialId) ?? null;
const root = document.createElement("div");
root.className = "ui-field";
const caption = document.createElement("label");
caption.className = "ui-field__label";
caption.textContent = label;
const row = document.createElement("div");
row.className = "b01-dashboard__asset-row";
const preview = document.createElement("img");
preview.className = "b01-dashboard__asset-preview";
preview.alt = "";
const name = document.createElement("span");
name.className = "b01-dashboard__asset-name";
const render = (): void => {
preview.hidden = !selected;
if (selected) preview.src = companyAssetFileUrl(selected.id);
name.textContent = selected ? selected.label : "(없음)";
};
render();
const pick = createButton({
label: "선택…",
variant: "ghost",
onClick: () =>
openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => {
list = fresh;
selected = next;
render();
}),
});
row.append(preview, name, pick);
root.append(caption, row);
return { root, value: () => selected?.id ?? null };
}
function openAssetPickerModal(
kind: CompanyAsset["kind"],
assets: CompanyAsset[],
selectedId: number | null,
companyId: number | null | undefined,
user: DashboardUser,
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
): void {
let list = assets;
const modal = document.createElement("div");
modal.className = "b01-dashboard__modal";
const panel = document.createElement("div");
panel.className = "b01-dashboard__modal-panel";
const heading = document.createElement("h3");
heading.className = "b01-dashboard__modal-title";
heading.textContent = `${KIND_LABEL[kind]} 선택`;
const grid = document.createElement("div");
grid.className = "b01-dashboard__asset-grid";
const close = (): void => modal.remove();
const choose = (asset: CompanyAsset | null): void => {
onPick(asset, list);
close();
};
const card = (asset: CompanyAsset | null): HTMLElement => {
const item = document.createElement("div");
item.className = "b01-dashboard__asset";
if ((asset?.id ?? null) === selectedId) item.classList.add("is-selected");
item.tabIndex = 0;
item.addEventListener("click", () => choose(asset));
item.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") choose(asset);
});
if (asset) {
const image = document.createElement("img");
image.src = companyAssetFileUrl(asset.id);
image.alt = asset.label;
const owner = asset.user_name ? asset.user_name : "회사 공용";
const text = document.createElement("span");
text.textContent = `${asset.label} · ${owner}`;
// 삭제는 두 번 눌러 확정 — 공용 confirm 모달은 이 모달 아래(z-index)에 깔려 못 쓴다.
const remove = createButton({ label: "삭제", variant: "ghost" });
remove.addEventListener("click", async (event) => {
event.stopPropagation();
if (remove.textContent !== "정말 삭제?") {
remove.textContent = "정말 삭제?";
return;
}
try {
await deleteCompanyAsset(asset.id);
list = list.filter((entry) => entry.id !== asset.id);
if (asset.id === selectedId) onPick(null, list);
item.remove();
} catch (error) {
showToast(error instanceof Error ? error.message : "삭제 실패", "error");
}
});
item.append(image, text, remove);
} else {
const text = document.createElement("span");
text.textContent = "(없음)";
item.append(text);
}
return item;
};
grid.append(card(null), ...list.map(card));
// 신규 추가 — 이름 + 그림 파일 + (내 계정에 물릴지)
const addTitle = document.createElement("h4");
addTitle.className = "b01-dashboard__modal-subtitle";
addTitle.textContent = "신규 추가";
const label = createInputField({ label: "이름", placeholder: `예: ${KIND_LABEL[kind]} 2026` });
const file = createInputField({ label: "그림 파일 (png·jpg·webp·svg, 2MB 이하)" });
file.input.type = "file";
file.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
const mine = document.createElement("label");
mine.className = "b01-dashboard__check";
const mineBox = document.createElement("input");
mineBox.type = "checkbox";
mineBox.checked = kind === "SIGNATURE";
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
const add = createButton({
label: "올리고 선택",
onClick: async () => {
const chosen = file.input.files?.[0];
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
if (!chosen) return file.setError("그림 파일을 고르세요.");
const form = new FormData();
form.append("kind", kind);
form.append("label", label.input.value.trim());
form.append("file", chosen);
if (mineBox.checked) form.append("user_id", String(user.id));
if (companyId) form.append("company_id", String(companyId));
try {
const id = await createCompanyAsset(form);
const fresh = await fetchCompanyAssets(companyId);
list = fresh.filter((asset) => asset.kind === kind);
choose(list.find((asset) => asset.id === id) ?? null);
} catch (error) {
showToast(error instanceof Error ? error.message : "업로드 실패", "error");
}
},
});
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
modal.append(panel);
document.body.append(modal);
}
+85 -31
View File
@@ -17,10 +17,13 @@ import {
joinCompany,
searchCompanies,
addCompanyMember,
fetchCompanyMembers,
fetchCompanyAssets,
type DashboardUser,
type ProjectItem,
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -66,8 +69,16 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
document.body.append(modal);
}
export function openEditProjectModal(user: DashboardUser, project: ProjectItem): void {
export async function openEditProjectModal(
user: DashboardUser,
project: ProjectItem,
): Promise<void> {
const isUserOnly = user.role === "USER";
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
const [members, assets] = await Promise.all([
fetchCompanyMembers(project.company_id),
fetchCompanyAssets(project.company_id),
]);
const name = createInputField({
label: L("B01_Dashboard_Table_Project"),
@@ -111,6 +122,34 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
type: "date",
value: (project.design_date ?? "").slice(0, 10),
});
const personOptions = [
{ value: "", text: "(미지정)" },
...members.map((member) => ({
value: String(member.id),
text: member.position ? `${member.name} (${member.position})` : member.name,
})),
];
const person = (label: string, current: number | null | undefined) =>
createSelectField({ label, options: personOptions, value: String(current ?? "") });
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
const logo = createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
project.logo_asset_id,
project.company_id,
user,
);
const signature = createAssetField(
"설계자 서명 (도면 표제란)",
"SIGNATURE",
assets,
project.signature_asset_id,
project.company_id,
user,
);
if (isUserOnly) {
name.input.disabled = true;
@@ -123,39 +162,54 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
projectNumber.input.disabled = true;
workAmount.input.disabled = true;
designDate.input.disabled = true;
pm.select.disabled = true;
fieldLead.select.disabled = true;
designer.select.disabled = true;
}
openModal(
L("B01_Dashboard_EditProject"),
[
name.root,
region.root,
roadType.root,
year.root,
length.root,
memo.root,
clientOrg.root,
projectNumber.root,
workAmount.root,
designDate.root,
],
async () => {
await updateProject(project.id, {
name: name.input.value.trim(),
region: region.input.value.trim() || null,
road_type: roadType.input.value.trim() || null,
project_year: year.input.value ? Number(year.input.value) : null,
estimated_length_m: length.input.value ? Number(length.input.value) : null,
memo: memo.input.value.trim() || null,
status: project.status,
client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
});
showToast(L("B01_Dashboard_Saved"), "success");
},
// 칸이 많아 두 줄 격자로 — 첫 칸(공사명)만 가로로 다 쓴다.
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(
name.root,
region.root,
roadType.root,
year.root,
length.root,
memo.root,
clientOrg.root,
projectNumber.root,
workAmount.root,
designDate.root,
pm.root,
fieldLead.root,
designer.root,
logo.root,
signature.root,
);
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
openModal(L("B01_Dashboard_EditProject"), [grid], async () => {
await updateProject(project.id, {
name: name.input.value.trim(),
region: region.input.value.trim() || null,
road_type: roadType.input.value.trim() || null,
project_year: year.input.value ? Number(year.input.value) : null,
estimated_length_m: length.input.value ? Number(length.input.value) : null,
memo: memo.input.value.trim() || null,
status: project.status,
client_org: clientOrg.input.value.trim() || null,
project_number: projectNumber.input.value.trim() || null,
work_amount: workAmount.input.value.trim() || null,
design_date: designDate.input.value || null,
pm_user_id: userId(pm.select),
field_lead_user_id: userId(fieldLead.select),
designer_user_id: userId(designer.select),
logo_asset_id: logo.value(),
signature_asset_id: signature.value(),
});
showToast(L("B01_Dashboard_Saved"), "success");
});
}
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
+66
View File
@@ -167,6 +167,8 @@
.b01-dashboard__modal-panel {
width: min(560px, 100%);
max-height: calc(100vh - 2 * var(--spacing-24));
overflow-y: auto;
background: var(--color-surface-raised);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
@@ -181,6 +183,70 @@
margin-bottom: var(--spacing-16);
}
.b01-dashboard__modal-subtitle {
margin: var(--spacing-24) 0 var(--spacing-8);
}
/* 로고·서명 칸 — 프로젝트 수정 모달 안 미리보기 줄과 고르기 모달의 격자 */
.b01-dashboard__asset-row {
display: flex;
align-items: center;
gap: var(--spacing-8);
min-height: 40px;
}
.b01-dashboard__asset-preview {
max-height: 36px;
max-width: 96px;
object-fit: contain;
}
.b01-dashboard__asset-name {
flex: 1;
font-size: var(--text-body-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b01-dashboard__asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-8);
}
.b01-dashboard__asset {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 2px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
cursor: pointer;
font-size: var(--text-body-sm);
text-align: center;
}
.b01-dashboard__asset.is-selected,
.b01-dashboard__asset:focus-visible {
border-color: var(--color-royal-amethyst);
outline: none;
}
.b01-dashboard__asset img {
max-height: 64px;
max-width: 100%;
object-fit: contain;
}
.b01-dashboard__check {
display: block;
margin: var(--spacing-8) 0 var(--spacing-16);
font-size: var(--text-body-sm);
}
@media (max-width: 860px) {
.b01-dashboard__header,
.b01-dashboard__grid {
+9 -3
View File
@@ -120,6 +120,9 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
사람 배정(과업책임자·분야별책임자·설계자) `projects` FK를 따라간다. 설계자는
배정이 없으면 프로젝트 소유자로 떨어진다 혼자 쓰는 계정에서도 칸이 차게.
로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다
골랐거나 자산이 지워졌으면 그림째 빠진다. 011 `companies.logo_path` ·
`users.signature_path` 읽지 않는다.
아직 채우는 자리와 이유:
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
@@ -132,9 +135,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
await cursor.execute(
"""
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
p.design_date, c.name, c.logo_path,
COALESCE(designer.name, owner.name),
COALESCE(designer.signature_path, owner.signature_path),
p.design_date, c.name, logo.file_path,
COALESCE(designer.name, owner.name), sig.file_path,
pm.name, lead.name
FROM projects p
LEFT JOIN companies c ON c.id = p.company_id
@@ -142,6 +144,10 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]:
LEFT JOIN users designer ON designer.id = p.designer_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 company_assets logo
ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL
LEFT JOIN company_assets sig
ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL
WHERE p.id = %s AND p.deleted_at IS NULL
""",
(str(project_id),),