diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 3b9332cc..1d55c657 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -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 { }); } -export async function fetchCompanyMembers(): Promise { - 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 { + const data = await request<{ members: Member[] }>( + `/dashboard/admin/members${companyQuery(companyId)}`, + ); return data.members; } +export async function fetchCompanyAssets(companyId?: number | null): Promise { + const data = await request<{ assets: CompanyAsset[] }>( + `/dashboard/company/assets${companyQuery(companyId)}`, + ); + return data.assets; +} + +/** multipart 업로드라 `request()`의 JSON 헤더를 쓰지 않는다. */ +export async function createCompanyAsset(form: FormData): Promise { + 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 { + 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 { return request("/dashboard/admin/members", { method: "POST", body: body({ email }) }); } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 920f9004..b78a2d69 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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, ), ) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Assets.py b/B01_Dashboard/B01_Dashboard_Repository_Assets.py new file mode 100644 index 00000000..c7fbe130 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Assets.py @@ -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 diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index b67ad343..46ad1b6d 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 76fcf3bd..d5d40b6f 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts new file mode 100644 index 00000000..2f43dd9d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -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); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 1510d387..9d114aae 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -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 { 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 { diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 00877848..cd70421d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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 { diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 3848ddab..49cc9d7e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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),),