diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 1d55c657..6aec62b9 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -68,6 +68,7 @@ export interface CompanyInfo { business_address?: string | null; business_owner?: string | null; business_status?: string | null; + logo_asset_id?: number | null; user_count?: number; project_count?: number; } @@ -232,7 +233,9 @@ export async function searchCompanies(query: string): Promise { return data.companies; } -export function createCompany(payload: CreateCompanyRequest): Promise { +export function createCompany( + payload: CreateCompanyRequest, +): Promise<{ company_id: number; status: string }> { return request("/dashboard/user/company/create", { method: "POST", body: body(payload) }); } @@ -277,6 +280,17 @@ export async function createCompanyAsset(form: FormData): Promise { return data.asset_id; } +/** 자산의 이름·주인을 고친다. 서명을 사람 계정에 물릴 때 쓴다 (2026-09-02). */ +export function updateCompanyAsset( + assetId: number, + payload: { label: string; user_id: number | null }, +): Promise { + return request(`/dashboard/company/assets/${assetId}`, { + method: "PUT", + body: body(payload), + }); +} + export function deleteCompanyAsset(assetId: number): Promise { return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" }); } @@ -284,8 +298,26 @@ export function deleteCompanyAsset(assetId: number): Promise { 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 }) }); +/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */ +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 { + return request(`/dashboard/company/logo${companyQuery(companyId)}`, { + method: "PUT", + body: body({ logo_asset_id: logoAssetId }), + }); } export function removeCompanyMember(userId: number): Promise { diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index b78a2d69..ec13d932 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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: await cursor.execute( """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 p.id) AS project_count FROM companies c @@ -409,58 +409,6 @@ async def join_company(user_id: int, company_id: int) -> dict[str, Any]: 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]]: where = "WHERE jr.company_id = %s" 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: await cursor.execute( """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 FROM companies c LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py new file mode 100644 index 00000000..0a7d9c1d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -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="로고·서명은 같은 회사 자산이어야 합니다.") diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 46ad1b6d..a0ba6723 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -6,13 +6,12 @@ from typing import Any 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_storage import read_stored_asset from config.config_system import PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( - add_company_member, assign_user_company, change_user_role, create_company, @@ -26,12 +25,10 @@ from .B01_Dashboard_Repository import ( list_all_projects, list_all_users, list_audit_logs, - list_company_members, list_company_projects, list_join_requests, list_user_projects, process_join_request, - remove_company_member, search_companies, soft_delete_project, update_admin_user, @@ -49,6 +46,13 @@ from .B01_Dashboard_Repository_Assets import ( update_company_asset, 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 ( AddMemberRequest, AdminUpdateUserRequest, @@ -58,6 +62,7 @@ from .B01_Dashboard_Schema import ( JoinCompanyRequest, ProcessJoinRequest, UpdateCompanyAssetRequest, + UpdateCompanyLogoRequest, UpdateProjectRequest, UpdateUserRequest, ) @@ -102,25 +107,6 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An 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 @@ -196,8 +182,10 @@ async def user_company_join( @router.get("/admin/members") async def admin_members( 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 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다. return { "status": "success", @@ -210,7 +198,15 @@ async def admin_add_member( payload: AddMemberRequest, session: dict[str, Any] = Depends(require_company_admin), ): - member = await add_company_member(_require_company_id(session), payload.email) + member = await add_company_member( + _require_company_id(session), + payload.email, + { + "name": payload.name, + "position": payload.position, + "department": payload.department, + }, + ) if not member: raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") return {"status": "success", "member": member} @@ -268,7 +264,7 @@ async def dashboard_update_project( if not _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") 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"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -475,6 +471,26 @@ async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends( 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") async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index d5d40b6f..3e69affb 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -25,6 +25,16 @@ class JoinCompanyRequest(BaseModel): class AddMemberRequest(BaseModel): email: str = Field(min_length=3, max_length=255) + # 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면 + # 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다. + name: str | None = Field(default=None, max_length=100) + position: str | None = Field(default=None, max_length=100) + department: str | None = Field(default=None, max_length=100) + + +class UpdateCompanyLogoRequest(BaseModel): + # 회사 대표 로고 (company_assets.id, kind=LOGO). None 이면 지정을 지운다. + logo_asset_id: int | None = Field(default=None, gt=0) class ProcessJoinRequest(BaseModel): diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 2f43dd9d..d16b079c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -20,6 +20,13 @@ export interface AssetFieldHandle { value: () => number | null; } +export interface AssetFieldOptions { + /** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */ + owner?: { id: number; name: string } | null; + /** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */ + onChange?: (assetId: number | null) => void | Promise; +} + const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const; /** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */ @@ -30,8 +37,14 @@ export function createAssetField( initialId: number | null | undefined, companyId: number | null | undefined, user: DashboardUser, + options: AssetFieldOptions = {}, ): 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; const root = document.createElement("div"); @@ -58,17 +71,124 @@ export function createAssetField( label: "선택…", variant: "ghost", onClick: () => - openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => { - list = fresh; - selected = next; - render(); - }), + openAssetPickerModal( + kind, + list, + selected?.id ?? null, + companyId, + user, + (next, fresh) => { + list = fresh; + selected = next; + render(); + void options.onChange?.(next?.id ?? null); + }, + owner, + ), }); row.append(preview, name, pick); root.append(caption, row); return { root, value: () => selected?.id ?? null }; } +/** + * 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다. + * 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다. + */ +function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise } { + 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, +): Promise { + const assets = await fetchCompanyAssets(companyId); + openAssetPickerModal( + kind, + assets.filter((asset) => asset.kind === kind), + currentId, + companyId, + user, + (next) => void onPick(next?.id ?? null), + ); +} + function openAssetPickerModal( kind: CompanyAsset["kind"], assets: CompanyAsset[], @@ -76,6 +196,7 @@ function openAssetPickerModal( companyId: number | null | undefined, user: DashboardUser, onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void, + owner: { id: number; name: string } | null = null, ): void { let list = assets; const modal = document.createElement("div"); @@ -150,19 +271,29 @@ function openAssetPickerModal( mine.className = "b01-dashboard__check"; const mineBox = document.createElement("input"); mineBox.type = "checkbox"; - mineBox.checked = kind === "SIGNATURE"; - mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`)); + mineBox.checked = owner !== null || kind === "SIGNATURE"; + // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. + mineBox.disabled = owner !== null; + mine.append( + mineBox, + document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`), + ); + const pad = kind === "SIGNATURE" ? createSignaturePad() : null; 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 chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; + if (!chosen) { + return file.setError( + pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.", + ); + } 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 (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id)); if (companyId) form.append("company_id", String(companyId)); try { const id = await createCompanyAsset(form); @@ -178,7 +309,9 @@ function openAssetPickerModal( 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); + panel.append(heading, grid, addTitle, label.root, file.root); + if (pad) panel.append(pad.root); + panel.append(mine, add, actions); modal.append(panel); document.body.append(modal); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 99509b9d..1da33720 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -1,11 +1,14 @@ import { createButton, createTag } from "@ui/ui_template_elements"; import { + fetchCompanyAssets, processJoinRequest, + setCompanyLogo, type CompanyInfo, type DashboardUser, type JoinRequest, type Member, } from "./B01_Dashboard_Api_Fetch"; +import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker"; import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { 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_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; } @@ -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( [ L("B01_Dashboard_Table_Company"), L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), + "로고", ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), + logoCell(company), ]), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 9d114aae..9a111b49 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -19,12 +19,18 @@ import { addCompanyMember, fetchCompanyMembers, fetchCompanyAssets, + updateCompanyAsset, + createCompanyAsset, + setCompanyLogo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ +const NEW_MEMBER = "__new__"; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -122,15 +128,42 @@ export async function openEditProjectModal( type: "date", value: (project.design_date ?? "").slice(0, 10), }); + const memberText = (member: Member) => + member.position ? `${member.name} (${member.position})` : member.name; const personOptions = [ { value: "", text: "(미지정)" }, - ...members.map((member) => ({ - value: String(member.id), - text: member.position ? `${member.name} (${member.position})` : member.name, - })), + ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), + { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; - const person = (label: string, current: number | null | undefined) => - createSelectField({ label, options: personOptions, value: String(current ?? "") }); + const persons: HTMLSelectElement[] = []; + 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 fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id); const designer = person("설계자 (도면 표제란)", project.designer_user_id); @@ -142,15 +175,6 @@ export async function openEditProjectModal( project.company_id, user, ); - const signature = createAssetField( - "설계자 서명 (도면 표제란)", - "SIGNATURE", - assets, - project.signature_asset_id, - project.company_id, - user, - ); - if (isUserOnly) { name.input.disabled = true; region.input.disabled = true; @@ -185,7 +209,6 @@ export async function openEditProjectModal( fieldLead.root, designer.root, logo.root, - signature.root, ); 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), designer_user_id: userId(designer.select), logo_asset_id: logo.value(), - signature_asset_id: signature.value(), + // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. + signature_asset_id: null, }); 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 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") { // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 name.input.disabled = true; @@ -257,7 +308,7 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar openModal( L("B01_Dashboard_EditUser"), - [name.root, position.root, department.root, phone.root], + [name.root, position.root, department.root, phone.root, signatureSlot], async () => { await updateDashboardUser(target.id, { name: name.input.value.trim(), @@ -306,18 +357,32 @@ export function openCreateCompanyModal(): void { }); const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); 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( L("B01_Dashboard_Modal_CreateCompany"), - [name.root, number.root, address.root, owner.root], + [name.root, number.root, address.root, owner.root, logo.root], async () => { if (!name.input.value.trim() || !number.input.value.trim()) return; - await createCompany({ + const created = await createCompany({ name: name.input.value.trim(), business_registration_number: number.input.value.trim(), business_address: address.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"); }, ); @@ -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({ label: L("B01_Dashboard_Field_MemberEmail"), type: "email", required: true, }); - openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => { - if (!email.input.value.trim()) return; - await addCompanyMember(email.input.value.trim()); - showToast(L("B01_Dashboard_Saved"), "success"); - }); + const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); + const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); + const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); + openModal( + L("B01_Dashboard_Modal_AddMember"), + [email.root, name.root, position.root, department.root], + async () => { + if (!email.input.value.trim()) return; + const created = await addCompanyMember(email.input.value.trim(), { + name: name.input.value.trim() || undefined, + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + if (created?.member) onCreated?.(created.member); + }, + ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 215d7ba4..66009c67 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -148,7 +148,7 @@ function buildPage(state: DashboardState): HTMLElement { createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), 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() }), ]), section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index cd70421d..de0a5d3f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -241,6 +241,18 @@ 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 { display: block; margin: var(--spacing-8) 0 var(--spacing-16); diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index da7989e9..06a1958f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -51,10 +51,12 @@ async def create_project( project_year: int, estimated_length_m: float | None, memo: str | None, + title_block: dict[str, Any] | None = None, ) -> dict[str, Any]: """신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다.""" project_id = str(uuid4()) + fields = title_block or {} storage_path, project_root = _build_project_storage(company_id, user_id, project_id) now = datetime.utcnow() @@ -67,9 +69,12 @@ async def create_project( INSERT INTO projects ( id, user_id, company_id, name, region, road_type, 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, @@ -84,6 +89,14 @@ async def create_project( storage_path, 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"), ), ) # 워크플로우 단계별 상태 초기화 시드 diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f053b985..ec160b60 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -4,6 +4,7 @@ from typing import Any 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 .B02_ProjRegister_Repository import create_project @@ -23,6 +24,21 @@ async def post_project( if company_id is None: 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: result = await create_project( user_id=int(session["user_id"]), @@ -33,6 +49,7 @@ async def post_project( project_year=payload.project_year, estimated_length_m=payload.estimated_length_m, memo=payload.memo.strip() if payload.memo else None, + title_block=title_block, ) return CreateProjectResponse(**result) except ValueError as exc: diff --git a/B02_ProjRegister/B02_ProjRegister_Schema.py b/B02_ProjRegister/B02_ProjRegister_Schema.py index 6b21c748..362ace0d 100644 --- a/B02_ProjRegister/B02_ProjRegister_Schema.py +++ b/B02_ProjRegister/B02_ProjRegister_Schema.py @@ -1,5 +1,7 @@ """B02_ProjRegister 요청/응답 스키마.""" +from datetime import date + from pydantic import BaseModel, Field @@ -8,10 +10,22 @@ class CreateProjectRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) 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) estimated_length_m: float | None = Field(default=None, ge=0) 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): diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 7aa195ae..cc697a71 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -20,6 +20,14 @@ import { showToast, } from "@ui/ui_template_elements"; 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 { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -88,6 +96,84 @@ export function renderB02ProjRegister(root: HTMLElement): void { 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({ label: L("B02_Proj_Submit"), variant: "filled", @@ -95,6 +181,9 @@ export function renderB02ProjRegister(root: HTMLElement): void { }); 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 { nameField.setError(); regionField.setError(); @@ -138,6 +227,14 @@ export function renderB02ProjRegister(root: HTMLElement): void { project_year: projectYear, estimated_length_m: estimatedLength, 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, lengthField.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 }); diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 49cc9d7e..b47861b8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -120,9 +120,15 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: 사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는 배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게. - 로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 — - 안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` · - `users.signature_path` 는 더 읽지 않는다. + **이름이 들어가는 자리는 서명도 받는다**(2026-09-02 사용자 확정) — 세 사람 모두 + 자기 계정의 `SIGNATURE` 자산을 따라가고, 안 올린 사람 자리는 그림째 빠진다. + 로고·서명은 회사 공유 자산(`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 프로젝트 수정 화면). 비어 있으면 빈칸. @@ -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, p.design_date, c.name, logo.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 LEFT JOIN companies c ON c.id = p.company_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 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 + ON logo.id = COALESCE(p.logo_asset_id, c.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 + 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 """, (str(project_id),), @@ -168,6 +203,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: signature_path, pm, lead, + pm_signature_path, + lead_signature_path, ) = row fields = { "공사명": name, @@ -183,6 +220,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: "분야별책임자": lead, "회사로고": _asset_data_url(logo_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} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index f7a1fafc..7a195985 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -19,7 +19,7 @@ import { export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ button: HTMLButtonElement; - /** 편집 중임을 알리는 CAD 화면 상단 띠 (평소엔 숨김). */ + /** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */ banner: HTMLElement; /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ isEditing: () => boolean; @@ -66,7 +66,10 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor variant: "ghost", 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({ label: "도각 편집", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 807467e4..575a7fd1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -601,7 +601,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }, onSaved: () => drawingCache.clear(), }); - cadHost.prepend(frameEditor.banner); window.addEventListener("message", (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; @@ -668,7 +667,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). 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); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index f2ca6ee6..c9a95180 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -126,11 +126,25 @@ /* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합). 여기서는 B07 고유 여백만 남긴다. */ +/* 액션 칸은 세로 두 줄 — 1행 도각 편집 띠, 2행 [도각 편집]·[현재 도면 확정]. + 공용 .ui-sidebar-actions는 가로 한 줄이라 방향과 늘어남을 여기서 되돌린다. */ .b07-drawing-actions { + flex-direction: column; 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%; } @@ -271,29 +285,36 @@ color: var(--color-text-muted); } -/* 도각 편집 모드 띠 — CAD 위에 겹쳐 편집 중임을 알리고 [완료]·[취소]를 준다. */ +/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과 + 겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */ .b07-frame-edit { - position: absolute; - z-index: 3; - top: 8px; - left: 50%; - transform: translateX(-50%); display: flex; - align-items: center; + flex-direction: column; gap: var(--spacing-8); - padding: 6px 10px; + padding: var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-cards); 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 { font-size: 0.78rem; + line-height: 1.4; color: var(--color-text); } -.b07-frame-edit > button { - padding: 4px 12px; +.b07-frame-edit__buttons { + 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; } diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 6f1fd7ad..0c21cede 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -1,4 +1,5 @@ /** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */ +import { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; import type { CadCommand } from './command.types'; import { bumpSceneVersion } from '../helpers/scene-version'; @@ -8,8 +9,23 @@ import { selectToolStateMachine } from '../tools/select-tool'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); - controller.setScreenScale(Math.max(0.01, controller.getScreenScale() * factor)); - return `줌 ${Math.round(controller.getScreenScale() * 100)}%`; + const oldScale = controller.getScreenScale(); + 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[] = [ diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 653ce154..c45c847c 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -19,7 +19,7 @@ import { ViewControls } from './ViewControls'; export const Toolbar: FC = () => { useCadRefresh(); 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; diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 4facbdbd..2afd797f 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -596,6 +596,11 @@ export class ScreenCanvasDrawController implements DrawController { angle: number ): void { if (this.batching) this.flushBatch(); + // 못 읽은 그림은 건너뛴다. 'broken' 상태의 그림을 그리려 하면 캔버스가 예외를 + // 던지고, 그 예외가 렌더 루프를 끊어 **이후 모든 도면이 백지**로 남았다 + // (2026-09-02 실측: 도각 자리표시자가 404 로 깨진 채 줌 한 번에 화면이 멈췄다). + // 자리 하나가 비는 것과 도면 전체가 안 나오는 것은 무게가 다르다. + if (imageElement.complete === false || imageElement.naturalWidth === 0) return; // 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과 // y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02 // 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다. diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index 3476aa8b..cca181dd 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -26,6 +26,13 @@ export class ImageEntity implements Entity { private imageElement: HTMLImageElement; private polygon: Polygon; private angle: number; + /** + * JSON에서 받은 그림 주소 원본. `imageElement.currentSrc`는 브라우저가 절대 URL로 + * 바꿔 놓아, 도각의 자리표시자(`{{회사로고}}`)가 저장 한 번에 + * `http://…/b07-cad/%7B%7B회사로고%7D%7D`로 굳는다(2026-09-02 실측 — 회사 도각이 + * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. + */ + private sourceData: string | null = null; constructor( layerId: string, @@ -115,7 +122,9 @@ export class ImageEntity implements Entity { public clone(): ImageEntity { const clonedImage = document.createElement('img'); 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 @@ -230,7 +239,7 @@ export class ImageEntity implements Entity { x: vertex.x, 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)) ); 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( jsonEntity.layerId || getActiveLayerId(), image, @@ -253,6 +267,7 @@ export class ImageEntity implements Entity { rectangleEntity.lineColor = jsonEntity.lineColor; rectangleEntity.lineWidth = jsonEntity.lineWidth; rectangleEntity.lineDash = jsonEntity.lineDash; + rectangleEntity.sourceData = jsonEntity.shapeData.imageData; return rectangleEntity; } } diff --git a/db_management/014_company_logo.sql b/db_management/014_company_logo.sql new file mode 100644 index 00000000..8c34b99f --- /dev/null +++ b/db_management/014_company_logo.sql @@ -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)'; diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index c3c7fe98..5ccc8f9c 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1277,6 +1277,62 @@ ], "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": [ @@ -1293,4 +1349,4 @@ "isLocked": false } ] -} \ No newline at end of file +} diff --git a/resources/template_2dDrawing/00_template_cover.json b/resources/template_2dDrawing/00_template_cover.json index 4dff3543..901b9426 100644 --- a/resources/template_2dDrawing/00_template_cover.json +++ b/resources/template_2dDrawing/00_template_cover.json @@ -332,6 +332,34 @@ "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": [ @@ -348,4 +376,4 @@ "isLocked": false } ] -} \ No newline at end of file +}