diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c768e8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 동봉한 LibreDWG 실행 파일 — 줄바꿈 변환이 닿으면 실행이 깨진다. +B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary +B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 94f672e5..b2795995 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -59,6 +59,8 @@ export interface ProjectItem { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + /** 참여자 (2026-09-06 사용자 확정) — 여기 든 사람은 일반 사용자여도 수정할 수 있다. */ + member_user_ids?: number[]; owner_name?: string | null; workflow_stage: number; progress_percent: number; @@ -171,6 +173,7 @@ export interface UpdateProjectRequest { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + member_user_ids?: number[] | null; } export interface AdminUpdateUserRequest extends UpdateUserRequest { @@ -306,17 +309,56 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; -/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */ -export function addCompanyMember( - email: string, - profile?: { name?: string; position?: string | null; department?: string | null }, -): Promise<{ member: Member }> { +/** 주소를 좌표로 바꾼다 (회사 주소 지도 미리보기). */ +export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number }> { + return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`); +} + +/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */ +export function updateCompany( + payload: { + name: string; + business_registration_number: string; + business_address: string | null; + business_owner: string | null; + }, + companyId?: number | null, +): Promise { + return request(`/dashboard/company${companyQuery(companyId)}`, { + method: "PUT", + body: body(payload), + }); +} + +/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */ +export async function searchMemberCandidates(query: string): Promise { + const data = await request<{ users: Member[] }>( + `/dashboard/admin/members/candidates?q=${encodeURIComponent(query)}`, + ); + return data.users; +} + +/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */ +export function addCompanyMember(userId: number): Promise<{ member: Member }> { return request("/dashboard/admin/members", { method: "POST", - body: body({ email, ...profile }), + body: body({ user_id: userId }), }); } +/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */ +export function inviteMember(email: string, name?: string | null): Promise { + return request("/dashboard/admin/members/invite", { + method: "POST", + body: body({ email, name: name || null }), + }); +} + +/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */ +export function deleteDashboardUser(userId: number): Promise { + return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" }); +} + /** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */ export function setCompanyLogo( logoAssetId: number | null, diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py new file mode 100644 index 00000000..6fbcf33c --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -0,0 +1,59 @@ +"""회사 주소 지도 — VWorld 주소검색·배경지도 타일 (2026-09-06 사용자 지시). + +이미 쓰던 VWorld 키를 그대로 쓴다. 키가 화면으로 새지 않게 타일도 서버가 받아 넘긴다. +""" + +from __future__ import annotations + +import asyncio +import json +import urllib.parse +import urllib.request +from typing import Any + +from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY + +_GEOCODE_URL = "https://api.vworld.kr/req/address" +_TILE_URL = "http://api.vworld.kr/req/wmts/1.0.0/{key}/Base/{z}/{y}/{x}.png" + + +def _fetch(url: str, timeout: int = 5) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def _geocode_sync(address: str) -> dict[str, Any] | None: + """도로명으로 먼저 찾고, 없으면 지번으로 다시 찾는다.""" + for address_type in ("ROAD", "PARCEL"): + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getcoord", + "version": "2.0", + "crs": "epsg:4326", + "address": address, + "refine": "true", + "simple": "false", + "format": "json", + "type": address_type, + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + continue + point = (body.get("response") or {}).get("result", {}).get("point") + if point: + return {"lon": float(point["x"]), "lat": float(point["y"])} + return None + + +async def geocode_address(address: str) -> dict[str, Any] | None: + return await asyncio.to_thread(_geocode_sync, address.strip()) + + +async def fetch_base_tile(z: int, x: int, y: int) -> bytes: + url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x) + return await asyncio.to_thread(_fetch, url) diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 5f284ae0..106f1fa6 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -11,13 +11,37 @@ import aiomysql import psutil from config.config_db import get_db_pool -from config.config_system import EMAIL_REVERIFY_DAYS +from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS def _role(value: str | None) -> str: return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER") +async def get_system_company_id() -> int | None: + """시스템 관리 회사 = `.env` 관리자 계정이 속한 회사 (2026-09-06 사용자 확정). + + 개발사 자기 회사 한 곳뿐이라 따로 표시 칸을 두지 않고 이 한 줄로 판정한다. + """ + if not ADMIN_EMAIL: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT company_id FROM users WHERE email = %s AND deleted_at IS NULL", + (ADMIN_EMAIL.lower(),), + ) + row = await cursor.fetchone() + return int(row["company_id"]) if row and row["company_id"] else None + + +async def role_for_company(company_id: int | None) -> str: + """회사에 들어갈 때 받는 역할 — 시스템 회사면 시스템관리자, 아니면 일반사용자.""" + if company_id is not None and int(company_id) == (await get_system_company_id() or 0): + return "SYSTEM_ADMIN" + return "USER" + + def _stage_from_status(status: str | None) -> tuple[int, int]: value = status or "NEW" if value in {"WF1_ANALYZING", "WF1_FAILED"}: @@ -46,11 +70,16 @@ def _project_row(row: dict[str, Any]) -> dict[str, Any]: async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + from .B01_Dashboard_Repository_Members import list_project_member_ids + states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows]) + members = await list_project_member_ids(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": []}) + # 참여자는 일반 사용자여도 그 프로젝트를 수정할 수 있다 (2026-09-06 사용자 확정). + p_row["member_user_ids"] = members.get(str(r["id"]), []) result.append(p_row) return result @@ -258,7 +287,14 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - ), ) changed = cursor.rowcount > 0 - if changed: + if not changed: + # 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 프로젝트가 없는 것과는 다르다 + # (참여자만 바꿀 때 「찾을 수 없습니다」로 튕기던 자리, 2026-09-06). + await cursor.execute( + "SELECT 1 FROM projects WHERE id = %s AND deleted_at IS NULL", (project_id,) + ) + changed = await cursor.fetchone() is not None + else: await cursor.execute( """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", @@ -288,202 +324,6 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool: return changed -async def get_user_company(company_id: int | None) -> dict[str, Any] | None: - if company_id is None: - return None - pool = get_db_pool() - 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.logo_asset_id, - 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 - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.id = %s AND c.deleted_at IS NULL - GROUP BY c.id""", - (company_id,), - ) - return await cursor.fetchone() - - -async def search_companies(query: str) -> list[dict[str, Any]]: - pool = get_db_pool() - pattern = f"%{query}%" - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, name, business_registration_number, business_status - FROM companies - WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) - ORDER BY name LIMIT 20""", - (pattern, pattern), - ) - return list(await cursor.fetchall()) - - -async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, master_user_id, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - user_id, - user_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """UPDATE users - SET company_id = %s, role = 'ADMIN', is_master = TRUE, - status = 'ACTIVE' - WHERE id = %s""", - (company_id, user_id), - ) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (user_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - actor_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (actor_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def join_company(user_id: int, company_id: int) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO join_requests (user_id, company_id, status) - VALUES (%s, %s, 'PENDING') - ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, - reviewed_by = NULL, reviewed_at = NULL""", - (user_id, company_id), - ) - request_id = cursor.lastrowid - await cursor.execute( - "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) - ) - await connection.commit() - return {"join_request_id": request_id, "status": "PENDING"} - except Exception: - await connection.rollback() - raise - - -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 () - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, - u.email AS user_email, u.name AS user_name, c.name AS company_name - FROM join_requests jr - JOIN users u ON u.id = jr.user_id - JOIN companies c ON c.id = jr.company_id - {where} - ORDER BY jr.requested_at DESC LIMIT 100""", - params, - ) - return list(await cursor.fetchall()) - - -async def process_join_request( - request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None -) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await connection.begin() - where = "id = %s AND status = 'PENDING'" - params: tuple[Any, ...] = (request_id,) - if company_id is not None: - where += " AND company_id = %s" - params = (request_id, company_id) - await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) - row = await cursor.fetchone() - if not row: - await connection.rollback() - return False - status = "APPROVED" if approved else "REJECTED" - await cursor.execute( - """UPDATE join_requests SET status = %s, reviewed_by = %s, - reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", - (status, reviewer_id, request_id), - ) - if approved: - await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE' - WHERE id = %s""", - (row["company_id"], row["user_id"]), - ) - else: - await cursor.execute( - "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) - ) - await connection.commit() - return True - - -async def list_all_companies() -> list[dict[str, Any]]: - pool = get_db_pool() - 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.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 - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" - ) - return list(await cursor.fetchall()) - - async def list_all_users() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: @@ -503,7 +343,11 @@ async def list_all_users() -> list[dict[str, Any]]: async def change_user_role(user_id: int, role: str) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id)) + # is_master 도 권한 판정에 쓰이므로 역할과 어긋나지 않게 함께 맞춘다. + await cursor.execute( + "UPDATE users SET role = %s, is_master = %s WHERE id = %s", + (role, role in ("ADMIN", "SYSTEM_ADMIN"), user_id), + ) changed = cursor.rowcount > 0 await connection.commit() return changed @@ -558,6 +402,26 @@ async def count_company_admins(company_id: int) -> int: return int(row["cnt"] if row else 0) +async def soft_delete_user(user_id: int) -> bool: + """사용자 계정을 지운다 (2026-09-06 사용자 확정 — 회사 관리자도 자기 회사 사람은 삭제). + + 지운 표시만 남기고 소속을 푼다. 상태가 INACTIVE 라 다음 요청에서 세션이 끊긴다. + 이메일은 그대로 둔다 — 같은 주소로 다시 가입하려면 시스템 관리자가 되살려야 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users + SET deleted_at = CURRENT_TIMESTAMP, status = 'INACTIVE', + company_id = NULL, is_master = FALSE + WHERE id = %s AND deleted_at IS NULL""", + (user_id,), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + async def assign_user_company(user_id: int, company_id: int | None) -> bool: status = "ACTIVE" if company_id else "NO_COMPANY" pool = get_db_pool() diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py new file mode 100644 index 00000000..e7a4eb12 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -0,0 +1,253 @@ +"""회사·가입 신청 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한). + +회사를 만들고 고치고 찾는 일, 그리고 그 회사에 들어가겠다는 신청을 다루는 곳이다. +사용자·프로젝트 쪽은 `B01_Dashboard_Repository` 에 남는다. +""" + +from __future__ import annotations + +from typing import Any + +import aiomysql + +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import role_for_company + + +async def get_user_company(company_id: int | None) -> dict[str, Any] | None: + if company_id is None: + return None + pool = get_db_pool() + 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.logo_asset_id, + 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 + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.id = %s AND c.deleted_at IS NULL + GROUP BY c.id""", + (company_id,), + ) + return await cursor.fetchone() + + +async def update_company(company_id: int, data: dict[str, Any]) -> bool: + """회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE companies + SET name = %s, business_registration_number = %s, + business_address = %s, business_owner = %s + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + company_id, + ), + ) + changed = cursor.rowcount > 0 + if not changed: + # 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 회사가 없는 것과는 다르다. + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (company_id,) + ) + changed = await cursor.fetchone() is not None + await connection.commit() + return changed + + +async def search_companies(query: str) -> list[dict[str, Any]]: + pool = get_db_pool() + pattern = f"%{query}%" + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, business_registration_number, business_status + FROM companies + WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) + ORDER BY name LIMIT 20""", + (pattern, pattern), + ) + return list(await cursor.fetchall()) + + +async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, master_user_id, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + user_id, + user_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """UPDATE users + SET company_id = %s, role = 'ADMIN', is_master = TRUE, + status = 'ACTIVE' + WHERE id = %s""", + (company_id, user_id), + ) + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (user_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + actor_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) + VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", + (actor_id, company_id), + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + # 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) — + # 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다. + await cursor.execute( + "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE", + (user_id,), + ) + current = await cursor.fetchone() + if current and current[0]: + await connection.rollback() + return None + await cursor.execute( + """INSERT INTO join_requests (user_id, company_id, status) + VALUES (%s, %s, 'PENDING') + ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, + reviewed_by = NULL, reviewed_at = NULL""", + (user_id, company_id), + ) + request_id = cursor.lastrowid + await cursor.execute( + "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) + ) + await connection.commit() + return {"join_request_id": request_id, "status": "PENDING"} + except Exception: + await connection.rollback() + raise + + +async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: + # 처리 끝난 신청은 목록에 남기지 않는다 (2026-09-06 사용자 지시) — 승인된 사람은 + # 사용자 관리 목록에 이미 있어 같은 사람이 두 번 보였다. + where = "WHERE jr.status = 'PENDING'" + (" AND jr.company_id = %s" if company_id else "") + params = (company_id,) if company_id else () + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, + u.email AS user_email, u.name AS user_name, c.name AS company_name + FROM join_requests jr + JOIN users u ON u.id = jr.user_id + JOIN companies c ON c.id = jr.company_id + {where} + ORDER BY jr.requested_at DESC LIMIT 100""", + params, + ) + return list(await cursor.fetchall()) + + +async def process_join_request( + request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None +) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + where = "id = %s AND status = 'PENDING'" + params: tuple[Any, ...] = (request_id,) + if company_id is not None: + where += " AND company_id = %s" + params = (request_id, company_id) + await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) + row = await cursor.fetchone() + if not row: + await connection.rollback() + return False + status = "APPROVED" if approved else "REJECTED" + await cursor.execute( + """UPDATE join_requests SET status = %s, reviewed_by = %s, + reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", + (status, reviewer_id, request_id), + ) + if approved: + # 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정). + role = await role_for_company(int(row["company_id"])) + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s + WHERE id = %s""", + (row["company_id"], role, row["user_id"]), + ) + else: + await cursor.execute( + "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) + ) + await connection.commit() + return True + + +async def list_all_companies() -> list[dict[str, Any]]: + pool = get_db_pool() + 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.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 + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" + ) + return list(await cursor.fetchall()) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 0a7d9c1d..14a57d54 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -6,16 +6,14 @@ 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 import _role, get_dashboard_me, role_for_company from .B01_Dashboard_Repository_Assets import list_company_assets @@ -34,53 +32,56 @@ async def list_company_members(company_id: int) -> list[dict[str, Any]]: return rows -async def add_company_member( - company_id: int, - email: str, - profile: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """회사에 사람을 붙인다. +async def list_unassigned_users(query: str) -> list[dict[str, Any]]: + """소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정). - `profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다** - (2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는 - 비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다. + 다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다. """ + like = f"%{query.strip()}%" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, position, email, department, status + FROM users + WHERE company_id IS NULL AND deleted_at IS NULL + AND status IN ('NO_COMPANY', 'PENDING') + AND (name LIKE %s OR email LIKE %s) + ORDER BY name, email LIMIT 20""", + (like, like), + ) + return list(await cursor.fetchall()) + + +async def attach_company_member(company_id: int, user_id: int) -> dict[str, Any] | None: + """가입한 사람을 회사에 붙인다. 소속이 이미 있으면 붙이지 않는다.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await connection.begin() await cursor.execute( """SELECT id, company_id FROM users - WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", - (email.lower(),), + WHERE id = %s AND deleted_at IS NULL FOR UPDATE""", + (user_id,), ) user = await cursor.fetchone() - if not user and profile and profile.get("name"): - await cursor.execute( - """INSERT INTO users (email, password_hash, name, position, department, - company_id, role, status) - VALUES (%s, %s, %s, %s, %s, %s, '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: + if not user or user["company_id"] is not None: await connection.rollback() return None await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s, is_master = FALSE WHERE id = %s""", - (company_id, user["id"]), + (company_id, await role_for_company(company_id), user_id), + ) + # 남아 있던 가입 신청은 닫는다 — 목록에 같은 사람이 두 번 보이지 않게 한다. + await cursor.execute( + """UPDATE join_requests + SET status = CASE WHEN company_id = %s THEN 'APPROVED' ELSE 'REJECTED' END, + reviewed_at = CURRENT_TIMESTAMP + WHERE user_id = %s AND status = 'PENDING'""", + (company_id, user_id), ) await connection.commit() - return await get_dashboard_me(user["id"]) + return await get_dashboard_me(user_id) async def remove_company_member(company_id: int, user_id: int) -> bool: @@ -121,6 +122,8 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: user_ids = { data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k) } + # 참여자도 같은 회사 사람이어야 한다 (2026-09-06 사용자 확정). + user_ids |= {int(uid) for uid in (data.get("member_user_ids") or [])} 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 = { @@ -132,3 +135,45 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: 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="로고·서명은 같은 회사 자산이어야 합니다.") + + +async def list_project_member_ids(cursor: Any, project_ids: list[str]) -> dict[str, list[int]]: + """프로젝트별 참여자 id 목록 (2026-09-06 사용자 확정).""" + if not project_ids: + return {} + marks = ", ".join(["%s"] * len(project_ids)) + await cursor.execute( + f"""SELECT project_id, user_id FROM project_members + WHERE project_id IN ({marks}) ORDER BY user_id""", + tuple(project_ids), + ) + result: dict[str, list[int]] = {} + for row in await cursor.fetchall(): + key = row["project_id"] if isinstance(row, dict) else row[0] + value = row["user_id"] if isinstance(row, dict) else row[1] + result.setdefault(str(key), []).append(int(value)) + return result + + +async def set_project_members(project_id: str, user_ids: list[int]) -> None: + """참여자 목록을 통째로 맞춘다. 만든 사람은 화면에서 늘 포함해 보낸다.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute("DELETE FROM project_members WHERE project_id = %s", (project_id,)) + for user_id in dict.fromkeys(user_ids): + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, int(user_id)), + ) + await connection.commit() + + +async def is_project_member(project_id: str, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT 1 FROM project_members WHERE project_id = %s AND user_id = %s", + (project_id, user_id), + ) + return await cursor.fetchone() is not None diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index a0227f68..119651f8 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -1,36 +1,45 @@ """B01_Dashboard 역할별 대시보드 API.""" +import html import mimetypes import os from typing import Any -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Path, + Query, + Response, + UploadFile, +) +from pymysql.err import IntegrityError from common_util.common_util_auth import require_company, require_system_admin, verify_session +from common_util.common_util_email import send_email from common_util.common_util_project_delete import hard_delete_project from common_util.common_util_storage import read_stored_asset -from config.config_system import PROJECT_DELETE_HARD_ENABLED +from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED +from .B01_Dashboard_Map import fetch_base_tile, geocode_address from .B01_Dashboard_Repository import ( assign_user_company, change_user_role, - create_company, + count_company_admins, get_dashboard_me, get_project, get_system_resources, get_user_admin_target, - get_user_company, - join_company, - list_all_companies, list_all_projects, list_all_users, list_audit_logs, list_company_projects, - list_join_requests, list_user_projects, - process_join_request, - search_companies, soft_delete_project, + soft_delete_user, update_admin_user, update_project, update_user_profile, @@ -46,12 +55,25 @@ from .B01_Dashboard_Repository_Assets import ( update_company_asset, write_company_asset_file, ) +from .B01_Dashboard_Repository_Company import ( + create_company, + get_user_company, + join_company, + list_all_companies, + list_join_requests, + process_join_request, + search_companies, + update_company, +) from .B01_Dashboard_Repository_Members import ( - add_company_member, + attach_company_member, check_project_refs, + is_project_member, list_company_members, + list_unassigned_users, remove_company_member, set_company_logo, + set_project_members, ) from .B01_Dashboard_Schema import ( AddMemberRequest, @@ -59,6 +81,7 @@ from .B01_Dashboard_Schema import ( AssignCompanyRequest, ChangeUserRoleRequest, CreateCompanyRequest, + InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, UpdateCompanyAssetRequest, @@ -107,12 +130,15 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An return asset -def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: +async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True if session["role"] == "ADMIN": return _same_company(session, project.get("company_id")) - return False + # 참여자로 지정된 일반 사용자도 수정할 수 있다 (2026-09-06 사용자 확정). + return _same_company(session, project.get("company_id")) and await is_project_member( + str(project["id"]), int(session["user_id"]) + ) def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool: @@ -144,7 +170,14 @@ async def patch_dashboard_me( @router.get("/user/projects") async def user_projects(session: dict[str, Any] = Depends(verify_session)): - return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))} + # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. + company_id = session.get("company_id") + projects = ( + await list_company_projects(int(company_id)) + if company_id + else await list_user_projects(int(session["user_id"])) + ) + return {"status": "success", "projects": projects} @router.get("/user/company") @@ -170,12 +203,66 @@ async def user_company_create( return {"status": "success", **result} +@router.put("/company") +async def company_update( + payload: CreateCompanyRequest, + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + """회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다.""" + try: + changed = await update_company(_scope_company(session, company_id), payload.model_dump()) + except IntegrityError as exc: + raise HTTPException( + status_code=409, detail="같은 회사명 또는 사업자등록번호가 이미 있습니다." + ) from exc + if not changed: + raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/company/geocode") +async def company_geocode( + address: str = Query(min_length=2, max_length=500), + session: dict[str, Any] = Depends(verify_session), +): + """주소를 좌표로 바꾼다 — 회사 등록·수정 화면의 지도 미리보기용.""" + _ = session + point = await geocode_address(address) + if not point: + raise HTTPException(status_code=404, detail="주소를 찾지 못했습니다.") + return {"status": "success", **point} + + +@router.get("/map/tile/{z}/{x}/{y}") +async def map_tile( + z: int = Path(ge=0, le=19), + x: int = Path(ge=0), + y: int = Path(ge=0), + session: dict[str, Any] = Depends(verify_session), +): + """배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다.""" + _ = session + if x >= 2**z or y >= 2**z: + raise HTTPException(status_code=400, detail="지도 타일 번호가 범위를 벗어났습니다.") + try: + tile = await fetch_base_tile(z, x, y) + except Exception as exc: # 지도 한 칸이 비는 것은 화면을 막을 일이 아니다 + raise HTTPException(status_code=502, detail="지도를 불러오지 못했습니다.") from exc + return Response(content=tile, media_type="image/png", headers={"Cache-Control": "max-age=3600"}) + + @router.post("/user/company/join") async def user_company_join( payload: JoinCompanyRequest, session: dict[str, Any] = Depends(verify_session), ): result = await join_company(int(session["user_id"]), payload.company_id) + if result is None: + raise HTTPException( + status_code=409, + detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.", + ) return {"status": "success", **result} @@ -193,25 +280,52 @@ async def admin_members( } +@router.get("/admin/members/candidates") +async def admin_member_candidates( + q: str = Query(min_length=2, max_length=100), + session: dict[str, Any] = Depends(require_company_admin), +): + """팀원으로 부를 수 있는 사람 — 소속이 없는 가입자만 (2026-09-06 사용자 확정).""" + _ = session + return {"status": "success", "users": await list_unassigned_users(q)} + + @router.post("/admin/members") async def admin_add_member( payload: AddMemberRequest, session: dict[str, Any] = Depends(require_company_admin), ): - member = await add_company_member( - _require_company_id(session), - payload.email, - { - "name": payload.name, - "position": payload.position, - "department": payload.department, - }, - ) + member = await attach_company_member(_require_company_id(session), payload.user_id) if not member: - raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") + raise HTTPException( + status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다." + ) return {"status": "success", "member": member} +@router.post("/admin/members/invite") +async def admin_invite_member( + payload: InviteMemberRequest, + session: dict[str, Any] = Depends(require_company_admin), +): + """아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다.""" + company = await get_user_company(session.get("company_id")) + company_name = (company or {}).get("name") or "회사" + # 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다. + safe_company = html.escape(company_name) + safe_name = html.escape(payload.name or "") + sent = await send_email( + payload.email, + f"[Aislo] {company_name} 팀원 등록 안내", + f"

{safe_name}님, {safe_company} 에서 Aislo 팀원으로 등록하려 합니다.

" + f"

아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.

" + f'

{APP_PUBLIC_BASE_URL}

', + ) + if not sent: + raise HTTPException(status_code=502, detail="안내 메일을 보내지 못했습니다.") + return {"status": "success"} + + @router.delete("/admin/members/{user_id}") async def admin_remove_member( user_id: int, session: dict[str, Any] = Depends(require_company_admin) @@ -261,7 +375,7 @@ async def dashboard_update_project( project = await get_project(project_id) if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if not _can_edit_project(session, project): + if not await _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). @@ -272,8 +386,12 @@ async def dashboard_update_project( detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", ) await check_project_refs(int(project["company_id"]), data) + member_ids = data.pop("member_user_ids", None) if not await update_project(project_id, data, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if member_ids is not None: + # 만든 사람은 늘 참여자로 남는다. + await set_project_members(project_id, [int(project["user_id"]), *member_ids]) return {"status": "success"} @@ -286,14 +404,10 @@ async def dashboard_delete_project( if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - can_del = False - if session["role"] == "SYSTEM_ADMIN": - can_del = True - # 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리 - # elif session["role"] == "ADMIN": - # if int(project.get("user_id") or 0) == int(session["user_id"]): - # can_del = True - + # 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). + can_del = session["role"] == "SYSTEM_ADMIN" or ( + session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) + ) if not can_del: raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") @@ -329,14 +443,29 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)): async def system_change_role( user_id: int, payload: ChangeUserRoleRequest, - session: dict[str, Any] = Depends(require_system_admin), + session: dict[str, Any] = Depends(require_company_admin), ): target = await get_user_admin_target(user_id) if not target: raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + # 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다. + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.") if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN": raise HTTPException( - status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다." + status_code=403, + detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.", + ) + # 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함). + if ( + target["role"] == "ADMIN" + and payload.role != "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", ) if not await change_user_role(user_id, payload.role): raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") @@ -362,6 +491,33 @@ async def admin_update_user( return {"status": "success"} +@router.delete("/admin/users/{user_id}") +async def admin_delete_user( + user_id: int, + session: dict[str, Any] = Depends(require_company_admin), +): + target = await get_user_admin_target(user_id) + if not target: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 지울 수 없습니다.") + if int(session["user_id"]) == user_id: + raise HTTPException(status_code=409, detail="본인 계정은 지울 수 없습니다.") + # 회사에 관리자가 하나도 남지 않게 되는 삭제는 막는다. + if ( + target["role"] == "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", + ) + if not await soft_delete_user(user_id): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + @router.patch("/admin/users/{user_id}/company") async def system_assign_company( user_id: int, diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index b9d791e0..ceab68a7 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -24,12 +24,16 @@ class JoinCompanyRequest(BaseModel): class AddMemberRequest(BaseModel): + # 팀원 등록은 「이미 가입한 사람을 고르는」 일이다 (2026-09-06 사용자 확정) — + # 계정을 대신 만들지 않는다. 소속이 없는 가입자만 고를 수 있다. + user_id: int = Field(gt=0) + + +class InviteMemberRequest(BaseModel): + """아직 가입하지 않은 사람에게 보내는 가입 안내 메일.""" + email: str = Field(min_length=3, max_length=255) - # 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면 - # 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다. name: str | None = Field(default=None, max_length=100) - position: str | None = Field(default=None, max_length=100) - department: str | None = Field(default=None, max_length=100) class UpdateCompanyLogoRequest(BaseModel): @@ -52,6 +56,9 @@ class AssignCompanyRequest(BaseModel): class UpdateProjectRequest(BaseModel): name: str = Field(min_length=1, max_length=255) + # 참여자 (2026-09-06 사용자 확정) — 도면 표제란 3역할과 별개로, 설계에 손대는 사람들. + # 참여자면 일반 사용자도 그 프로젝트를 수정할 수 있다. 비우면 지금 값을 그대로 둔다. + member_user_ids: list[int] | None = Field(default=None) region: str | None = Field(default=None, max_length=100) road_type: str | None = Field(default=None, max_length=100) project_year: int | None = Field(default=None, ge=1900, le=2100) diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 7874e27b..3da6941b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -1,7 +1,11 @@ import { createButton } from "@ui/ui_template_elements"; import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { canChangeRole } from "./B01_Dashboard_UI_Helper"; -import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals"; +import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; +import { + openChangeRoleModal, + openDeleteUserModal, + openEditUserModal, +} from "./B01_Dashboard_UI_Modals"; import { table, text } from "@ui/ui_template_general_blocks"; import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; @@ -39,6 +43,17 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } + // 회사에서 빼기·계정 삭제 (2026-09-06 사용자 지시) — 범위는 백엔드가 다시 본다. + if (canDeleteUser(currentUser, user) && user.id !== currentUser.id) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_DeleteUser"), + variant: "ghost", + onClick: () => openDeleteUserModal(user), + }), + ); + } + return [ text(user.email), text(user.name), diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 1aadb7eb..780028be 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -303,13 +303,16 @@ function openAssetPickerModal( mineBox.checked = owner !== null || kind === "SIGNATURE"; // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. mineBox.disabled = owner !== null; + // 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다. mine.append( mineBox, - document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`), + document.createTextNode( + ` 이 그림을 ${owner ? owner.name : `내 계정(${user.name})`}의 것으로 지정 (풀면 회사 공용)`, + ), ); const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ - label: "올리고 선택", + label: "파일 올리고 이 프로젝트에 쓰기", onClick: async () => { if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 65b6eccd..17f51a19 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { + createInputField, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, @@ -142,3 +143,67 @@ export function attachModalDismiss( return { isDirty, tryClose }; } + +/** + * 사용자 정보 입력칸 한 벌 — 기본정보 폼과 사용자 관리 수정 모달이 같은 것을 쓴다 + * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 + * 이메일은 계정 식별자라 읽기 전용이다. + */ +export function buildUserFields(source: { + name: string; + email?: string; + position?: string | null; + department?: string | null; + phone?: string | null; +}): { + grid: HTMLElement; + validate: () => boolean; + values: () => { + name: string; + position: string | null; + department: string | null; + phone: string | null; + }; +} { + const name = createInputField({ + label: L("B01_Account_Field_Name"), + value: source.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: source.position ?? "", + }); + const email = createInputField({ + label: L("B01_Dashboard_Field_MemberEmail"), + type: "email", + value: source.email ?? "", + }); + email.input.disabled = true; + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: source.department ?? "", + }); + const phone = createInputField({ + label: L("B01_Account_Field_Phone"), + value: source.phone ?? "", + }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(name.root, position.root, email.root, department.root, phone.root); + return { + grid, + validate: () => { + name.setError(); + if (name.input.value.trim()) return true; + name.setError(L("Common_Msg_RequiredField")); + return false; + }, + values: () => ({ + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + }), + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 1da33720..b6a7af21 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -12,6 +12,7 @@ import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicke import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, + openEditCompanyModal, openCreateCompanyModal, openDeleteUserModal, openEditUserModal, @@ -44,6 +45,17 @@ 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-06 사용자 지시) — 관리자만 보인다. + if (state.user.role !== "USER" && state.company) { + const company = state.company; + wrap.append( + createButton({ + label: "회사 정보 수정", + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }), + ); + } // 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정). // 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다. if (state.user.role !== "USER") { @@ -173,12 +185,20 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), "로고", + L("B01_Dashboard_Table_Action"), ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), logoCell(company), + user + ? createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }) + : text(""), ]), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts index 261f491b..fa423208 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Helper.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -1,15 +1,15 @@ import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch"; -export function canEditProject(user: DashboardUser, _project: ProjectItem): boolean { +export function canEditProject(user: DashboardUser, project: ProjectItem): boolean { if (user.role === "SYSTEM_ADMIN") return true; if (user.role === "ADMIN") return user.company_id !== null; - return false; // USER는 수정 불가 + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + return (project.member_user_ids ?? []).includes(user.id); } export function canDeleteProject(user: DashboardUser): boolean { - // SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리) - return user.role === "SYSTEM_ADMIN"; - // return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 회사 관리자도 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). 범위는 백엔드가 다시 본다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canAddUser(user: DashboardUser): boolean { @@ -17,8 +17,9 @@ export function canAddUser(user: DashboardUser): boolean { } export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { - // 역할 변경은 오직 SYSTEM_ADMIN만 가능 - return user.role === "SYSTEM_ADMIN"; + // 회사 관리자도 자기 회사 안에서 역할을 바꾼다 (2026-09-06 사용자 확정). + // 마지막 관리자 이탈·시스템 관리자 변경은 백엔드가 막는다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts new file mode 100644 index 00000000..d87137b6 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -0,0 +1,72 @@ +import { API_BASE_URL } from "@config/config_frontend"; +import { geocodeAddress } from "./B01_Dashboard_Api_Fetch"; + +/** + * 주소 지도 미리보기 (2026-09-06 사용자 지시). + * + * 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다. + * 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다. + */ +const ZOOM = 15; +const TILE = 256; +const GRID = 3; + +function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } { + const n = 2 ** ZOOM; + const rad = (lat * Math.PI) / 180; + const fx = ((lon + 180) / 360) * n; + const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n; + return { x: Math.floor(fx), y: Math.floor(fy), dx: fx - Math.floor(fx), dy: fy - Math.floor(fy) }; +} + +export function buildAddressMap(): { + root: HTMLElement; + show: (address: string) => Promise; +} { + const root = document.createElement("div"); + root.className = "b01-dashboard__map"; + const note = document.createElement("p"); + note.className = "b01-dashboard__modal-text"; + note.textContent = "주소를 넣고 「지도 확인」을 누르십시오."; + root.append(note); + + const show = async (address: string): Promise => { + root.innerHTML = ""; + if (!address.trim()) { + note.textContent = "주소를 먼저 입력하십시오."; + root.append(note); + return; + } + const point = await geocodeAddress(address).catch(() => null); + if (!point) { + note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."; + root.append(note); + return; + } + const center = tileIndex(point.lat, point.lon); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__map-grid"; + const half = Math.floor(GRID / 2); + for (let row = -half; row <= half; row += 1) { + for (let col = -half; col <= half; col += 1) { + const img = document.createElement("img"); + img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`; + img.width = TILE; + img.height = TILE; + img.alt = ""; + grid.append(img); + } + } + const marker = document.createElement("span"); + marker.className = "b01-dashboard__map-marker"; + // 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다. + marker.style.left = `${(half + center.dx) * TILE * 0.5}px`; + marker.style.top = `${(half + center.dy) * TILE * 0.5}px`; + const frame = document.createElement("div"); + frame.className = "b01-dashboard__map-frame"; + frame.append(grid, marker); + root.append(frame); + }; + + return { root, show }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 59845ba2..9af03c60 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -14,24 +14,31 @@ import { updateDashboardUser, removeCompanyMember, createCompany, + updateCompany, joinCompany, searchCompanies, addCompanyMember, + searchMemberCandidates, + inviteMember, + deleteDashboardUser, fetchCompanyMembers, fetchCompanyAssets, fetchUserCompany, updateCompanyAsset, createCompanyAsset, setCompanyLogo, + type CompanyInfo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; -import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; - -/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ -const NEW_MEMBER = "__new__"; +import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview"; +import { + attachModalDismiss, + buildUserFields, + type ModalDismissHandle, +} from "./B01_Dashboard_UI_Common"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -89,7 +96,8 @@ export async function openEditProjectModal( user: DashboardUser, project: ProjectItem, ): Promise { - const isUserOnly = user.role === "USER"; + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + const isUserOnly = user.role === "USER" && !(project.member_user_ids ?? []).includes(user.id); // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). const [members, assets, company] = await Promise.all([ @@ -164,9 +172,10 @@ export async function openEditProjectModal( const personOptions = [ { value: "", text: "(미지정)" }, ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), - { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; const persons: HTMLSelectElement[] = []; + // 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) — + // 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다. const person = (label: string, current: number | null | undefined) => { const field = createSelectField({ label, @@ -174,25 +183,6 @@ export async function openEditProjectModal( value: String(current ?? ""), }); persons.push(field.select); - let last = field.select.value; - // 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다. - field.select.addEventListener("change", () => { - if (field.select.value !== NEW_MEMBER) { - last = field.select.value; - return; - } - field.select.value = last; - openAddMemberModal((member) => { - for (const select of persons) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - select.insertBefore(option, select.options[select.options.length - 1]); - } - field.select.value = String(member.id); - last = field.select.value; - }); - }); return field; }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); @@ -255,6 +245,27 @@ export async function openEditProjectModal( designer.root, logo.root, ); + // 참여자 — 도면 표제란 3역할과 별개로 설계에 손대는 사람들 (2026-09-06 사용자 확정). + const memberBox = document.createElement("div"); + memberBox.className = "b01-dashboard__members"; + const memberLabel = document.createElement("p"); + memberLabel.className = "b01-dashboard__modal-text"; + memberLabel.textContent = "참여자 (고른 사람은 이 프로젝트를 수정할 수 있음)"; + memberBox.append(memberLabel); + const memberChecks: HTMLInputElement[] = []; + for (const member of members) { + const row = document.createElement("label"); + row.className = "b01-dashboard__member-row"; + const check = document.createElement("input"); + check.type = "checkbox"; + check.value = String(member.id); + check.checked = (project.member_user_ids ?? []).includes(member.id); + check.disabled = isUserOnly; + memberChecks.push(check); + row.append(check, document.createTextNode(` ${memberText(member)}`)); + memberBox.append(row); + } + const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. @@ -262,7 +273,7 @@ export async function openEditProjectModal( openModal( L("B01_Dashboard_EditProject"), - [grid], + [grid, memberBox], async () => { await updateProject(project.id, { name: name.input.value.trim(), @@ -284,6 +295,7 @@ export async function openEditProjectModal( logo_asset_id: logo.value(), // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. signature_asset_id: null, + member_user_ids: memberChecks.filter((c) => c.checked).map((c) => Number(c.value)), }); showToast(L("B01_Dashboard_Saved"), "success"); }, @@ -306,22 +318,25 @@ export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem } export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void { - const name = createInputField({ - label: L("B01_Dashboard_Table_Name"), - value: target.name, - required: true, + // 기본정보 폼과 같은 칸 한 벌을 쓴다 (2026-09-06 사용자 지시). + const fields = buildUserFields({ + name: target.name, + email: target.email, + position: target.position, + department: target.department, + phone: (target as DashboardUser).phone, }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: target.position ?? "", + const isAdmin = user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 관리자는 계정을 비활성화할 수 있다 (2026-09-06 사용자 지시) — 비활성 계정은 다음 + // 요청에서 세션이 끊긴다. + const statusField = createSelectField({ + label: L("B01_Dashboard_Table_Status"), + options: [ + { value: "ACTIVE", text: "활성" }, + { value: "INACTIVE", text: "비활성" }, + ], + value: target.status === "INACTIVE" ? "INACTIVE" : "ACTIVE", }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: target.department ?? "", - }); - - const phoneVal = (target as DashboardUser).phone || ""; - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. @@ -330,51 +345,30 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar void fetchCompanyAssets(companyId).then((assets) => { const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id); signatureSlot.append( - createAssetField( - "서명 (도면 표제란)", - "SIGNATURE", - assets, - owned?.id ?? null, - companyId, - user, - { - owner: { id: target.id, name: target.name }, - onChange: async (assetId) => { - if (assetId === null) return; - const picked = assets.find((asset) => asset.id === assetId); - if (picked) - await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); - }, + createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, companyId, user, { + owner: { id: target.id, name: target.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); }, - ).root, + }).root, ); }); - if (user.role === "ADMIN") { - // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 - name.input.disabled = true; - phone.input.disabled = true; - } else if (user.role === "USER" && user.id !== target.id) { - name.input.disabled = true; - position.input.disabled = true; - department.input.disabled = true; - phone.input.disabled = true; - } - - openModal( - L("B01_Dashboard_EditUser"), - [name.root, position.root, department.root, phone.root, signatureSlot], - async () => { - await updateDashboardUser(target.id, { - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - status: target.status, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - }, - ); + const rows = isAdmin + ? [fields.grid, statusField.root, signatureSlot] + : [fields.grid, signatureSlot]; + openModal(L("B01_Dashboard_EditUser"), rows, async () => { + if (!fields.validate()) return; + await updateDashboardUser(target.id, { + ...fields.values(), + // 상태는 관리자만 바꾼다. 그 밖에는 지금 값을 그대로 되돌려 보낸다. + status: isAdmin ? statusField.select.value : target.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }); } export function openChangeRoleModal(target: Member | DashboardUser): void { @@ -394,45 +388,141 @@ export function openChangeRoleModal(target: Member | DashboardUser): void { } export function openDeleteUserModal(target: Member | DashboardUser): void { + // 두 가지가 다르다 — 회사에서 빼면 계정은 남고(무소속), 계정 삭제는 로그인 자체가 막힌다 + // (2026-09-06 사용자 확정). + const mode = createSelectField({ + label: "처리 방식", + options: [ + { value: "REMOVE", text: "회사에서 빼기 (계정은 남음)" }, + { value: "DELETE", text: "계정 삭제 (로그인 불가)" }, + ], + value: "REMOVE", + }); const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); - openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => { - await removeCompanyMember(target.id); + openModal(L("B01_Dashboard_DeleteUser"), [mode.root, warning], async () => { + if (mode.select.value === "DELETE") await deleteDashboardUser(target.id); + else await removeCompanyMember(target.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } -export function openCreateCompanyModal(): void { - const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true }); +/** 회사 등록·수정이 함께 쓰는 입력칸 — 순서는 사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소. */ +function companyFields(company?: CompanyInfo): { + rows: HTMLElement[]; + values: () => { + name: string; + business_registration_number: string; + business_address: string | null; + business_owner: string | null; + }; + logoFile: () => File | undefined; + valid: () => boolean; +} { const number = createInputField({ label: L("B01_Dashboard_Field_BusinessNumber"), + value: company?.business_registration_number ?? "", required: true, }); - const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); - const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); - // 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다. + const name = createInputField({ + label: L("B01_Dashboard_Table_Company"), + value: company?.name ?? "", + required: true, + }); + const owner = createInputField({ + label: L("B01_Dashboard_Field_Owner"), + value: company?.business_owner ?? "", + }); const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" }); logo.input.type = "file"; logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg"; + const address = createInputField({ + label: L("B01_Dashboard_Field_Address"), + value: company?.business_address ?? "", + }); + // 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시). + const map = buildAddressMap(); + const mapBtn = createButton({ + label: "지도 확인", + variant: "ghost", + onClick: async function onB01_Company_Map_Click() { + await map.show(address.input.value.trim()); + }, + }); + const addressRow = document.createElement("div"); + addressRow.append(address.root, mapBtn, map.root); + + return { + rows: [number.root, name.root, owner.root, logo.root, addressRow], + values: () => ({ + 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, + }), + logoFile: () => logo.input.files?.[0], + valid: () => Boolean(name.input.value.trim() && number.input.value.trim()), + }; +} + +export function openCreateCompanyModal(): void { + const fields = companyFields(); + // 같은 회사를 두 번 만들지 않게, 등록 전에 이미 있는 회사를 찾아 보여 준다 + // (2026-09-06 사용자 지시). + const matches = document.createElement("div"); + matches.className = "b01-dashboard__actions"; + const findBtn = createButton({ + label: "이미 있는 회사인지 찾기", + variant: "ghost", + onClick: async function onB01_Company_Duplicate_Click() { + matches.innerHTML = ""; + const values = fields.values(); + const query = values.business_registration_number || values.name; + if (!query) return; + const found = await searchCompanies(query); + if (found.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__modal-text"; + empty.textContent = "같은 회사가 없습니다. 그대로 등록하십시오."; + matches.append(empty); + return; + } + for (const company of found) { + matches.append( + createButton({ + label: `${company.name} — ${L("B01_Dashboard_JoinCompany")}`, + variant: "ghost", + onClick: async () => { + showLoadingOverlay(); + try { + await joinCompany(company.id); + showToast(L("B01_Dashboard_Saved"), "success"); + } catch { + showToast("요청 실패", "error"); + } finally { + hideLoadingOverlay(); + } + }, + }), + ); + } + }, + }); openModal( L("B01_Dashboard_Modal_CreateCompany"), - [name.root, number.root, address.root, owner.root, logo.root], + [...fields.rows, findBtn, matches], async () => { - if (!name.input.value.trim() || !number.input.value.trim()) return; - 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 (!fields.valid()) return; + const values = fields.values(); + const created = await createCompany(values); + const file = fields.logoFile(); if (file && created?.company_id) { const form = new FormData(); form.append("kind", "LOGO"); - form.append("label", `${name.input.value.trim()} 로고`); + form.append("label", `${values.name} 로고`); form.append("file", file); form.append("company_id", String(created.company_id)); const assetId = await createCompanyAsset(form); @@ -443,6 +533,27 @@ export function openCreateCompanyModal(): void { ); } +/** 회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체. */ +export function openEditCompanyModal(company: CompanyInfo): void { + const fields = companyFields(company); + openModal("회사 정보 수정", fields.rows, async () => { + if (!fields.valid()) return; + const values = fields.values(); + await updateCompany(values, company.id); + const file = fields.logoFile(); + if (file) { + const form = new FormData(); + form.append("kind", "LOGO"); + form.append("label", `${values.name} 로고`); + form.append("file", file); + form.append("company_id", String(company.id)); + const assetId = await createCompanyAsset(form); + await setCompanyLogo(assetId, company.id); + } + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + export function openFindCompanyModal(): void { const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true }); const results = document.createElement("div"); @@ -482,32 +593,80 @@ export function openFindCompanyModal(): void { } /** - * 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다** - * (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다. + * 팀원 등록 — 이미 가입한 사람 중 **소속이 없는 사람**만 골라 붙인다 + * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 + * 안내 메일만 보낸다. */ -// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`). -// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것. export function openAddMemberModal(onCreated?: (member: Member) => void): void { - const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), - type: "email", - required: true, + const query = createInputField({ + label: "이름 또는 이메일로 찾기", + placeholder: "두 글자 이상", }); - const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); - const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); - const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); - openModal( - L("B01_Dashboard_Modal_AddMember"), - [email.root, name.root, position.root, department.root], - async () => { - if (!email.input.value.trim()) return; - const created = await addCompanyMember(email.input.value.trim(), { - name: name.input.value.trim() || undefined, - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - if (created?.member) onCreated?.(created.member); + const results = document.createElement("div"); + results.className = "b01-dashboard__actions"; + let picked: Member | null = null; + + const search = createButton({ + label: L("Common_Btn_Search"), + variant: "ghost", + onClick: async function onB01_Member_Search_Click() { + results.innerHTML = ""; + const text = query.input.value.trim(); + if (text.length < 2) { + query.setError("두 글자 이상 입력하십시오."); + return; + } + query.setError(); + const users = await searchMemberCandidates(text); + if (users.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__modal-text"; + empty.textContent = "소속 없는 가입자가 없습니다. 아직 가입 전이면 안내 메일을 보내십시오."; + results.append(empty); + return; + } + for (const candidate of users) { + results.append( + createButton({ + label: `${candidate.name} (${candidate.email})`, + variant: picked?.id === candidate.id ? "filled" : "ghost", + onClick: () => { + picked = candidate; + query.input.value = `${candidate.name} (${candidate.email})`; + results.innerHTML = ""; + }, + }), + ); + } }, - ); + }); + + const invite = createButton({ + label: "가입 안내 메일 보내기", + variant: "ghost", + onClick: async function onB01_Member_Invite_Click() { + const text = query.input.value.trim(); + if (!text.includes("@")) { + query.setError("메일을 보낼 이메일 주소를 입력하십시오."); + return; + } + query.setError(); + await inviteMember(text); + showToast("안내 메일을 보냈습니다.", "success"); + }, + }); + + const bar = document.createElement("div"); + bar.className = "b01-dashboard__actions"; + bar.append(search, invite); + + openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => { + if (!picked) { + query.setError("등록할 사람을 먼저 고르십시오."); + throw new Error("등록할 사람을 먼저 고르십시오."); + } + const created = await addCompanyMember(picked.id); + 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 66009c67..6e821eea 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -151,7 +151,6 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [ createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), - section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), ); } else if (state.user.role === "ADMIN") { grid.append( @@ -181,6 +180,10 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); + // 시스템 로그는 맨 아래 (2026-09-06 사용자 지시). + if (state.user.role === "SYSTEM_ADMIN") { + grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); + } page.append(grid); return page; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index ef110687..90db3ab9 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -1,44 +1,18 @@ import { isBlank } from "@util/common_util_validate"; import { createButton, createInputField } from "@ui/ui_template_elements"; import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { L, runRequest } from "./B01_Dashboard_UI_Common"; +import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; const PASSWORD_MIN_LENGTH = 8; export function buildProfileForm(user: DashboardUser): HTMLElement { - const name = createInputField({ - label: L("B01_Account_Field_Name"), - value: user.name, - required: true, - }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: user.position ?? "", - }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: user.department ?? "", - }); - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); - const grid = document.createElement("div"); - grid.className = "b01-dashboard__form-grid"; - grid.append(name.root, position.root, department.root, phone.root); + const fields = buildUserFields(user); + const grid = fields.grid; const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { - name.setError(); - if (isBlank(name.input.value)) { - name.setError(L("Common_Msg_RequiredField")); - return; - } - await runRequest(() => - updateUserProfile({ - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - }), - ); + if (!fields.validate()) return; + await runRequest(() => updateUserProfile(fields.values())); }, }); const wrap = document.createElement("div"); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index de0a5d3f..15fde60c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -273,3 +273,46 @@ grid-template-columns: 1fr; } } + +/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */ +.b01-dashboard__map-frame { + position: relative; + width: 100%; + max-width: 384px; + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 8px); +} + +.b01-dashboard__map-grid { + display: grid; + grid-template-columns: repeat(3, 256px); + transform: scale(0.5); + transform-origin: top left; +} + +.b01-dashboard__map-marker { + position: absolute; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border: 2px solid var(--color-surface, #fff); + border-radius: 50%; + background: var(--color-danger, #d33); +} + +/* 프로젝트 참여자 고르기 (2026-09-06). */ +.b01-dashboard__members { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + max-height: 160px; + overflow-y: auto; +} + +.b01-dashboard__member-row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 8a184ec7..9c3d68b0 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -102,6 +102,11 @@ async def create_project( fields.get("logo_asset_id"), ), ) + # 만든 사람은 곧 참여자다 (2026-09-06 사용자 확정) — 참여자는 수정 권한을 가진다. + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, user_id), + ) # 워크플로우 단계별 상태 초기화 시드 await initialize_project_stages(cursor, project_id) diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 5609d184..935e3dba 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -27,7 +27,6 @@ import { fetchUserCompany, } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker"; -import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -55,11 +54,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { header.append(title, subtitle); // 입력 필드 + // 프로젝트명은 사업연도 + 사업지역 + 임도종류 + 직접 입력값을 이어 붙여 만든다 + // (2026-09-06 사용자 확정). 아래 미리보기 줄이 저장될 이름 그대로다. const nameField = createInputField({ - label: L("B02_Proj_Field_Name"), - placeholder: L("B02_Proj_Field_Name_Placeholder"), + label: "프로젝트명 (직접 입력 부분)", + placeholder: "예: 가리왕산지구", required: true, }); + const namePreview = document.createElement("p"); + namePreview.className = "b02-proj__preview"; const regionField = createInputField({ label: L("B02_Proj_Field_Region"), placeholder: L("B02_Proj_Field_Region_Placeholder"), @@ -84,12 +87,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { min: 2000, max: currentYear + 5, }); - const lengthField = createInputField({ - label: L("B02_Proj_Field_Length"), - placeholder: L("B02_Proj_Field_Length_Placeholder"), - type: "number", - min: 0, - }); // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). // 둘 다 비우면 전 구간을 쓴다. const routeStartField = createInputField({ @@ -113,14 +110,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다 // (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다. const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" }); - const projectNumberField = createInputField({ - label: "연도·기번 (표지)", - placeholder: "예: 2026년 간선임도(기번3-울진.대흥)", - }); - const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" }); + // 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과 + // 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다. const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); - const NEW_MEMBER = "__new__"; const person = (label: string) => createSelectField({ label, options: [{ value: "", text: "(미지정)" }] }); const pmField = person("과업책임자 (도면 표제란)"); @@ -150,28 +143,9 @@ export function renderB02ProjRegister(root: HTMLElement): void { }; for (const select of personSelects) { for (const member of members) addOption(select, String(member.id), memberText(member)); - // 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다. - if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…"); - } - for (const select of personSelects) { - let last = select.value; - select.addEventListener("change", () => { - if (select.value !== NEW_MEMBER) { - last = select.value; - return; - } - select.value = last; - openAddMemberModal((member) => { - for (const other of personSelects) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - other.insertBefore(option, other.options[other.options.length - 1]); - } - select.value = String(member.id); - last = select.value; - }); - }); + // 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는 + // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. + if (members.some((member) => member.id === me.id)) select.value = String(me.id); } if (company) { // 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다. @@ -188,30 +162,71 @@ export function renderB02ProjRegister(root: HTMLElement): void { } })(); + const roadTypeText = (): string => + roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; + const composedName = (): string => + [ + yearField.input.value.trim(), + regionField.input.value.trim(), + roadTypeText(), + nameField.input.value.trim(), + ] + .filter((part) => part.length > 0) + .join(" "); + const routeLength = (): number | null => { + const start = isBlank(routeStartField.input.value) + ? null + : Number.parseFloat(routeStartField.input.value); + const end = isBlank(routeEndField.input.value) + ? null + : Number.parseFloat(routeEndField.input.value); + if (end === null || !Number.isFinite(end)) return null; + const from = start !== null && Number.isFinite(start) ? start : 0; + return end > from ? end - from : null; + }; + const refresh = (): void => { + namePreview.textContent = `저장될 이름: ${composedName() || "(입력 대기)"}`; + const length = routeLength(); + routeEndField.root.querySelector(".ui-field__label")!.textContent = + length === null + ? "노선 종료 누가거리 (m)" + : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; + }; + for (const field of [nameField, regionField, yearField, routeStartField, routeEndField]) { + field.input.addEventListener("input", refresh); + } + roadTypeField.select.addEventListener("change", refresh); + refresh(); + const submitBtn = createButton({ label: L("B02_Proj_Submit"), variant: "filled", onClick: onB02_Proj_Submit_Click, }); submitBtn.classList.add("b02-proj__submit"); + const cancelBtn = createButton({ + label: L("Common_Btn_Cancel"), + variant: "ghost", + onClick: function onB02_Proj_Cancel_Click() { + navigateTo(ROUTES.B01_ACCOUNT); + }, + }); const idOrNull = (select: HTMLSelectElement): number | null => - select.value && select.value !== NEW_MEMBER ? Number(select.value) : null; + select.value ? Number(select.value) : null; async function onB02_Proj_Submit_Click(): Promise { nameField.setError(); regionField.setError(); yearField.setError(); - lengthField.setError(); routeStartField.setError(); routeEndField.setError(); // 1차 유효성: 필수값 검사 let hasError = false; const projectYear = Number.parseInt(yearField.input.value, 10); - const estimatedLength = isBlank(lengthField.input.value) - ? null - : Number.parseFloat(lengthField.input.value); + // 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정). + const estimatedLength = routeLength(); if (isBlank(nameField.input.value)) { nameField.setError(L("B02_Proj_Error_Required")); hasError = true; @@ -224,10 +239,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { yearField.setError(L("Common_Validation_NumberRange")); hasError = true; } - if (estimatedLength !== null && (!Number.isFinite(estimatedLength) || estimatedLength < 0)) { - lengthField.setError(L("Common_Validation_NumberRange")); - hasError = true; - } const routeStart = isBlank(routeStartField.input.value) ? null : Number.parseFloat(routeStartField.input.value); @@ -256,7 +267,7 @@ export function renderB02ProjRegister(root: HTMLElement): void { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - name: nameField.input.value.trim(), + name: composedName(), region: regionField.input.value.trim(), road_type: roadTypeField.select.value, project_year: projectYear, @@ -265,8 +276,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { route_end_m: routeEnd, memo: memoField.input.value.trim() || null, client_org: clientOrgField.input.value.trim() || null, - project_number: projectNumberField.input.value.trim() || null, - work_amount: workAmountField.input.value.trim() || null, + // 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정). + project_number: composedName(), + work_amount: + estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`, design_date: designDateField.input.value || null, pm_user_id: idOrNull(pmField.select), field_lead_user_id: idOrNull(fieldLeadField.select), @@ -301,17 +314,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { const grid = document.createElement("div"); grid.className = "b02-proj__grid"; grid.append( - nameField.root, + yearField.root, regionField.root, roadTypeField.root, - yearField.root, - lengthField.root, + nameField.root, + namePreview, routeStartField.root, routeEndField.root, memoField.root, clientOrgField.root, - projectNumberField.root, - workAmountField.root, designDateField.root, pmField.root, fieldLeadField.root, @@ -319,7 +330,10 @@ export function renderB02ProjRegister(root: HTMLElement): void { logoSlot, ); - const card = createCard({ body: [grid, submitBtn], raised: true }); + const actions = document.createElement("div"); + actions.className = "b02-proj__actions"; + actions.append(cancelBtn, submitBtn); + const card = createCard({ body: [grid, actions], raised: true }); page.append(header, card); root.append(page); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index 233f8f2d..97e37525 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -63,3 +63,17 @@ grid-template-columns: 1fr; } } + +/* 조합된 프로젝트명 미리보기 — 저장될 이름 그대로 보인다 (2026-09-06). */ +.b02-proj__preview { + margin: calc(-1 * var(--spacing-8)) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 취소·등록 버튼 줄 */ +.b02-proj__actions { + display: flex; + gap: var(--spacing-8); + justify-content: flex-end; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index e842eae7..34ab74a7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -77,10 +77,7 @@ export interface CrossDesignInfo { cross_slope_pct?: number; paved?: boolean; ditch: DitchSpec; - road_edges?: Record< - "left" | "right", - { offset_m: number; elevation_m: number } - >; + road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -120,10 +117,7 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson( - path: string, - init: RequestInit = {}, -): Promise { +async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -134,17 +128,14 @@ async function requestJson( signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; - if (!response.ok) - throw new Error(payload.message ?? `HTTP ${response.status}`); + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timeoutId); } } -export function fetchDesignDrawingList( - projectId: string, -): Promise { +export function fetchDesignDrawingList(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -152,9 +143,7 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson( - `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, - ); + return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); } export function confirmDesignDrawing( @@ -172,10 +161,7 @@ export function confirmDesignDrawing( ); } -export function invalidateDesignDrawing( - projectId: string, - drawingId: string, -): Promise { +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, @@ -189,24 +175,66 @@ export interface FrameTemplateResponse { drawing: CadDrawing; /** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */ customized: boolean; + /** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */ + fields?: Record; } -export function fetchFrameTemplate( - projectId: string, -): Promise { +export function fetchFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate( - projectId: string, - drawing: CadDrawing, -): Promise { +export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), }); } +/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */ +export async function importFrameTemplate( + projectId: string, + file: File, +): Promise<{ drawing: CadDrawing; entity_count: number }> { + const form = new FormData(); + form.append("file", file); + // 파일 전송이라 requestJson(JSON 헤더·짧은 시한)을 쓰지 않는다. + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/frame-template/import`, { + method: "POST", + credentials: "include", + body: form, + }); + const payload = (await response.json()) as { + drawing: CadDrawing; + entity_count: number; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +/** 지금 도면을 DXF·DWG 파일로 받는다 (2026-09-06 사용자 지시) — 파일은 서버가 만든다. */ +export async function exportDrawing( + projectId: string, + drawing: CadDrawing, + fileFormat: "dxf" | "dwg", + name: string, +): Promise<{ blob: Blob; skipped: number }> { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drawing-export`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ drawing, file_format: fileFormat, name }), + }); + if (!response.ok) { + const detail = (await response.json().catch(() => ({}))) as { message?: string }; + throw new Error(detail.message ?? `HTTP ${response.status}`); + } + return { + blob: await response.blob(), + skipped: Number(response.headers.get("X-Aislo-Skipped") ?? 0), + }; +} + /** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */ export function resetFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py new file mode 100644 index 00000000..535fdaf2 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -0,0 +1,170 @@ +"""B07 도면 내보내기 — 캐드 도면(JSON)을 DXF·DWG 파일로 바꾼다 (2026-09-06 사용자 지시). + +불러오기(`..._Engine_Frame_Import`)의 반대 방향이다. 도형은 ezdxf 로 DXF 를 쓰고, +DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불러 바꾼다 — 라이브러리로 +끌어안으면 GPL 이 이 프로그램까지 번진다. + +그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를 +건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다. +""" + +from __future__ import annotations + +import logging +import math +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import ezdxf +from ezdxf.enums import TextEntityAlignment + +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import bundled_tool + +logger = logging.getLogger(__name__) + +# 파일로 내려보내는 DXF 형식. DWG 로 갈 때는 LibreDWG 가 쓰는 R2004 로 맞춘다 — +# 더 새 형식을 주면 한글이 깨진 채로 DWG 에 박힌다(2026-09-06 실측). +_DXF_VERSION = "R2018" +_DWG_SOURCE_VERSION = "R2004" +_DEFAULT_TEXT_MM = 3.0 + + +def _xy(point: Any) -> tuple[float, float] | None: + if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): + return float(point["x"]), float(point["y"]) + return None + + +def _layer_name(raw: Any) -> str: + """DXF 도면층 이름 규칙에 맞춘다 — 빈 이름과 금지 문자를 걸러 낸다.""" + name = str(raw or "0").strip() + for bad in '<>/\\":;?*|=`': + name = name.replace(bad, "_") + return name[:255] or "0" + + +def _add_entity( + space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int] +) -> None: + """도형 하나를 DXF 에 적는다. 자식이 있으면 자식까지 따라 내려간다.""" + if not isinstance(entity, dict): + return + layer = _layer_name(entity.get("layerId")) + if layer not in layers: + space.doc.layers.add(layer) + layers.add(layer) + attribs = {"layer": layer} + shape = entity.get("shapeData") or {} + kind = entity.get("type") + + if kind == "Image": + # 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다. + skipped["Image"] = skipped.get("Image", 0) + 1 + return + + start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) + if start and end: + space.add_line(start, end, dxfattribs=attribs) + elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): + options = shape.get("options") or {} + height = float(options.get("fontSize") or _DEFAULT_TEXT_MM) + direction = _xy(options.get("textDirection")) or (1.0, 0.0) + rotation = math.degrees(math.atan2(direction[1], direction[0])) + text = space.add_text( + shape["label"], + dxfattribs={**attribs, "height": height, "rotation": rotation}, + ) + # 자리표는 칸 한가운데에 선다 — 내보낸 파일에서도 같은 자리에 오게 가운데 맞춤. + if options.get("boxWidth") and options.get("boxHeight"): + text.set_placement(base, align=TextEntityAlignment.MIDDLE_CENTER) + else: + text.set_placement(base) + elif point := _xy(shape.get("point")): + space.add_point(point, dxfattribs=attribs) + elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): + radius = float(shape["radius"]) + start_angle = shape.get("startAngle") + end_angle = shape.get("endAngle") + if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)): + space.add_arc( + center, + radius, + math.degrees(float(start_angle)), + math.degrees(float(end_angle)), + dxfattribs=attribs, + ) + else: + space.add_circle(center, radius, dxfattribs=attribs) + else: + vertices = [xy for vertex in shape.get("points") or [] if (xy := _xy(vertex))] + if len(vertices) >= 2: + space.add_lwpolyline(vertices, close=True, dxfattribs=attribs) + + for child in entity.get("children") or []: + _add_entity(space, child, layers, skipped) + + +def drawing_to_dxf( + drawing: dict[str, Any], version: str = _DXF_VERSION +) -> tuple[bytes, dict[str, int]]: + """캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다.""" + entities = drawing.get("entities") + if not isinstance(entities, list) or not entities: + raise ValueError("내보낼 도형이 없습니다.") + document = ezdxf.new(version) + space = document.modelspace() + layers: set[str] = {layer.dxf.name for layer in document.layers} + skipped: dict[str, int] = {} + for entity in entities: + _add_entity(space, entity, layers, skipped) + with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: + path = Path(temporary) / "drawing.dxf" + document.saveas(path) + return path.read_bytes(), skipped + + +def _dxf2dwg_path() -> str | None: + """LibreDWG 의 DWG 쓰기 도구 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" + configured = os.getenv("LIBREDWG_DXF2DWG_PATH", "").strip() + if configured: + return configured if Path(configured).is_file() else None + return bundled_tool("dxf2dwg.exe") or shutil.which("dxf2dwg") + + +def dxf_to_dwg(dxf_bytes: bytes) -> bytes: + """DXF 를 DWG 로 바꾼다. 변환기가 없으면 「DXF 로 받으라」는 안내와 함께 실패.""" + converter = _dxf2dwg_path() + if not converter: + raise ValueError("이 서버는 아직 DWG 로 내보내지 못합니다. DXF 로 내려받아 주십시오.") + with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: + work_dir = Path(temporary) + source = work_dir / "drawing.dxf" + target = work_dir / "drawing.dwg" + source.write_bytes(dxf_bytes) + try: + subprocess.run( + [converter, "-o", str(target), str(source)], + check=True, + timeout=180, + capture_output=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.info("B07 도면 DWG 내보내기 실패: %s", exc) + raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") from exc + if not target.is_file() or target.stat().st_size == 0: + raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") + return target.read_bytes() + + +def export_drawing(drawing: dict[str, Any], file_format: str) -> tuple[bytes, dict[str, int]]: + """도면을 요청한 형식(dxf·dwg) 파일 바이트로 낸다.""" + if file_format == "dxf": + return drawing_to_dxf(drawing) + if file_format == "dwg": + dxf_bytes, skipped = drawing_to_dxf(drawing, _DWG_SOURCE_VERSION) + return dxf_to_dwg(dxf_bytes), skipped + raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py new file mode 100644 index 00000000..0cb7dd28 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -0,0 +1,280 @@ +"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다. + +고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다. +**LibreDWG 의 `dwg2dxf`** 로 바꿔 읽는다 (2026-09-06 사용자 확정). ODA File Converter 는 +비회원 무료 사용이 **비상업 용도로 제한**돼 이 프로그램에는 쓰지 않는다. + +LibreDWG 는 별도 실행 파일로만 부른다 — 라이브러리로 끌어안으면 GPL 이 이 프로그램까지 +번진다. 별도 프로세스 호출은 그 의무가 생기지 않는다. + +읽는 범위는 **R2018(AC1032) 까지**다. 그보다 새 형식이나 변환 실패는 「R2018 이하 또는 +DXF 로 저장해 달라」는 안내로 떨어진다. + +프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를 +직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다). +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tempfile +from math import cos, radians, sin +from pathlib import Path +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +import ezdxf +from ezdxf import colors as ezdxf_colors +from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode + +logger = logging.getLogger(__name__) + +_IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import") +_DEFAULT_COLOR = "#f5f7fa" +# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. +_FLATTEN_MM = 0.2 +_MAX_ENTITIES = 20000 + + +# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`. +# 별도 실행 파일로만 부른다(라이브러리로 품지 않는다) — 자세한 것은 그 폴더의 README. +_BUNDLED_DIR = Path(__file__).resolve().parent / "openwebcad" / "tools" / "libredwg" + + +def bundled_tool(name: str) -> str | None: + """동봉한 변환기 경로. 없으면 None.""" + path = _BUNDLED_DIR / name + return str(path) if path.is_file() else None + + +def _entity_id(index: int) -> str: + return str(uuid5(_IMPORT_NS, str(index))) + + +def _color(entity: Any) -> str: + """DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다.""" + try: + aci = int(entity.dxf.color) + if aci in (0, 256): # BYBLOCK · BYLAYER + return _DEFAULT_COLOR + red, green, blue = ezdxf_colors.aci2rgb(aci) + return f"#{red:02x}{green:02x}{blue:02x}" + except Exception: + return _DEFAULT_COLOR + + +def _point(x: float, y: float) -> dict[str, float]: + return {"x": float(x), "y": float(y)} + + +def _base(index: int, entity: Any, kind: str) -> dict[str, Any]: + return { + "id": _entity_id(index), + "type": kind, + "lineColor": _color(entity), + "lineWidth": 1, + "layerId": str(getattr(entity.dxf, "layer", "0")), + } + + +def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]: + return { + **_base(index, entity, "Line"), + "shapeData": { + "startPoint": _point(start[0], start[1]), + "endPoint": _point(end[0], end[1]), + }, + } + + +def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None: + """점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다.""" + vertices = [(float(p[0]), float(p[1])) for p in points] + if closed and len(vertices) > 2: + vertices.append(vertices[0]) + if len(vertices) < 2: + return None + children = [ + { + **_base(index, entity, "Line"), + "id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")), + "shapeData": { + "startPoint": _point(*vertices[seq]), + "endPoint": _point(*vertices[seq + 1]), + }, + } + for seq in range(len(vertices) - 1) + ] + return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children} + + +def _plain(label: str) -> str: + """옛 DXF 는 한글을 유니코드 escape 로 적는다 — 글자로 되돌린다.""" + return decode_dxf_unicode(label) if has_dxf_unicode(label) else label + + +def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: + rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) + return { + **_base(index, entity, "Text"), + "shapeData": { + "label": _plain(label), + "basePoint": _point(insert[0], insert[1]), + "options": { + "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), + "textAlign": "left", + "textColor": _color(entity), + "fontSize": float(height) or 3.0, + "fontFamily": "sans-serif", + }, + }, + } + + +def _flatten(entity: Any) -> list[Any] | None: + """원·호·타원·스플라인을 선분 점열로 편다. 못 펴면 None.""" + try: + return list(entity.flattening(_FLATTEN_MM)) + except Exception: + return None + + +def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: + kind = entity.dxftype() + if kind == "LINE": + return [_line(index, entity, entity.dxf.start, entity.dxf.end)] + if kind == "LWPOLYLINE": + shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) + return [shape] if shape else [] + if kind == "POLYLINE": + points = [vertex.dxf.location for vertex in entity.vertices] + shape = _polyline(index, entity, points, bool(entity.is_closed)) + return [shape] if shape else [] + if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): + points = _flatten(entity) + if not points: + return [] + shape = _polyline(index, entity, points, kind in ("CIRCLE", "ELLIPSE")) + return [shape] if shape else [] + if kind == "POINT": + location = entity.dxf.location + return [ + { + **_base(index, entity, "Point"), + "shapeData": {"point": _point(location[0], location[1])}, + } + ] + if kind == "TEXT": + label = str(entity.dxf.text or "").strip() + if not label: + return [] + return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] + if kind == "MTEXT": + label = str(entity.plain_text() or "").strip() + if not label: + return [] + height = float(entity.dxf.char_height or 3.0) + return [_text(index, entity, label, entity.dxf.insert, height)] + return [] + + +def _expand(entity: Any) -> list[Any]: + """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" + if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): + try: + return list(entity.virtual_entities()) + except Exception: + logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) + return [] + return [entity] + + +def dxf_to_entities(path: Path) -> list[dict[str, Any]]: + """DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다.""" + document = ezdxf.readfile(str(path)) + entities: list[dict[str, Any]] = [] + index = 0 + for source in document.modelspace(): + for item in _expand(source): + entities.extend(_convert_entity(index, item)) + index += 1 + if len(entities) > _MAX_ENTITIES: + raise ValueError( + f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)." + " 도각만 남겨 다시 저장해 주십시오." + ) + if not entities: + raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.") + return entities + + +# DWG 머리글의 형식 표시(앞 6바이트) — 읽을 수 있는 것과 사람이 읽을 이름. +_DWG_VERSIONS: dict[str, str] = { + "AC1014": "R14", + "AC1015": "2000", + "AC1018": "2004", + "AC1021": "2007", + "AC1024": "2010", + "AC1027": "2013", + "AC1032": "2018", +} +_SAVE_AS_GUIDE = ( + "캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오." +) + + +def dwg_version(data: bytes) -> str | None: + """DWG 머리글에서 형식 이름을 읽는다. 우리가 아는 형식이 아니면 None.""" + return _DWG_VERSIONS.get(data[:6].decode("ascii", "ignore")) + + +def _dwg2dxf_path() -> str | None: + """LibreDWG 변환기(dwg2dxf) 자리 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" + configured = os.getenv("LIBREDWG_DWG2DXF_PATH", "").strip() + if configured: + return configured if Path(configured).is_file() else None + return bundled_tool("dwg2dxf.exe") or shutil.which("dwg2dxf") + + +def _dwg_to_dxf(source: Path, work_dir: Path) -> Path: + """LibreDWG 로 DWG 를 DXF 로 바꾼다. 못 읽는 형식·변환기 없음은 안내와 함께 실패.""" + with source.open("rb") as handle: + version = dwg_version(handle.read(6)) + if version is None: + raise ValueError(f"이 DWG 는 R2018 이후이거나 알 수 없는 형식입니다. {_SAVE_AS_GUIDE}") + converter = _dwg2dxf_path() + if not converter: + raise ValueError(f"이 서버는 아직 DWG 를 바로 읽지 못합니다. {_SAVE_AS_GUIDE}") + target = work_dir / "converted.dxf" + try: + subprocess.run( + [converter, "-o", str(target), str(source)], + check=True, + timeout=180, + capture_output=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.info("B07 도각 DWG 변환 실패(%s): %s", version, exc) + raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") from exc + if not target.is_file() or target.stat().st_size == 0: + raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") + return target + + +def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: + """올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다.""" + suffix = Path(filename).suffix.lower() + if suffix not in (".dxf", ".dwg"): + raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.") + with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary: + work_dir = Path(temporary) + source = work_dir / f"frame{suffix}" + source.write_bytes(data) + target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source + try: + return dxf_to_entities(target) + except ezdxf.DXFError as exc: + raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 6708d945..e0e20440 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -20,13 +20,12 @@ from pathlib import Path from typing import Any from uuid import NAMESPACE_URL, uuid5 -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines - from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, DRAWING_FORMAT, FRAME_LAYER_ID, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing" @@ -114,9 +113,14 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: 1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다. """ + return frame_document(template_entities(name)) + + +def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]: + """엔티티 목록을 도각 편집 화면이 그대로 싣는 도면 한 장으로 감싼다 (실치수 1:1).""" return { "format": DRAWING_FORMAT, - "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in template_entities(name)], + "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities], "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f4bb7d50..e81516c3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -27,10 +27,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( - clear_company_template, - company_template_path, - frame_template_document, - save_company_template, use_company_templates, use_title_fields, ) @@ -59,9 +55,6 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingInvalidateResponse, DesignDrawingListResponse, DesignDrawingResponse, - FrameTemplateResponse, - FrameTemplateSaveRequest, - FrameTemplateSaveResponse, ) from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path @@ -121,7 +114,7 @@ def _asset_data_url(relative_path: str | None) -> str: return f"data:{mime};base64,{b64encode(blob).decode('ascii')}" -async def _title_block_fields(project_id: UUID) -> dict[str, str]: +async def title_block_fields(project_id: UUID) -> dict[str, str]: """도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다. 담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이 @@ -282,7 +275,7 @@ async def get_design_drawing( # 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다. use_company_templates(project_root.parent.parent) # 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다. - use_title_fields(await _title_block_fields(project_id)) + use_title_fields(await title_block_fields(project_id)) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. design: dict[str, Any] | None = None source_design: Any = None @@ -562,71 +555,3 @@ async def invalidate_design_drawing( status_code=500, content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, ) - - -@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse) -async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse: - """도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본.""" - try: - company_dir = await _company_dir(project_id) - use_company_templates(company_dir) - return FrameTemplateResponse( - project_id=str(project_id), - drawing=frame_template_document(), - customized=company_template_path(company_dir).is_file(), - ) - except FileNotFoundError as exc: - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "도각을 읽지 못했습니다."}, - ) - - -@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) -async def put_frame_template( - project_id: UUID, request: FrameTemplateSaveRequest -) -> FrameTemplateSaveResponse | JSONResponse: - """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. - - 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 - 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). - """ - try: - entities = request.drawing.get("entities") - if not isinstance(entities, list): - raise ValueError("도각 엔티티가 없습니다.") - company_dir = await _company_dir(project_id) - await asyncio.to_thread(save_company_template, company_dir, entities) - return FrameTemplateSaveResponse(project_id=str(project_id)) - except (FileNotFoundError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 저장 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "도각을 저장하지 못했습니다."}, - ) - - -@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) -async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: - """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). - - 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. - 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. - """ - try: - company_dir = await _company_dir(project_id) - removed = await asyncio.to_thread(clear_company_template, company_dir) - return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed) - except FileNotFoundError as exc: - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."}, - ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py new file mode 100644 index 00000000..6ad39fab --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -0,0 +1,193 @@ +"""B07 도각·내보내기 라우터 (B07_DesignDetail_Router 에서 분리, 700줄 제한). + +도각을 읽고 고치고 되돌리는 길, 외부 도각 파일(DXF·DWG) 불러오기, 그리고 캐드 도면을 +DXF·DWG 파일로 내보내는 길을 한곳에 둔다. +""" + +import asyncio +import logging +import re +from pathlib import Path +from urllib.parse import quote +from uuid import UUID + +from fastapi import APIRouter, File, Response, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + clear_company_template, + company_template_path, + frame_document, + frame_template_document, + save_company_template, + use_company_templates, + validate_template_entities, +) +from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields +from B07_DesignDetail.B07_DesignDetail_Schema import ( + DrawingExportRequest, + FrameTemplateImportResponse, + FrameTemplateResponse, + FrameTemplateSaveRequest, + FrameTemplateSaveResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + + +async def _company_dir(project_id: UUID) -> Path: + """프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + root = Path(resolve_stored_project_path(stored_path)).resolve() + return root.parent.parent + + +@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse) +async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse: + """도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본.""" + try: + company_dir = await _company_dir(project_id) + use_company_templates(company_dir) + # 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는 + # 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다). + fields = await title_block_fields(project_id) + return FrameTemplateResponse( + project_id=str(project_id), + drawing=frame_template_document(), + customized=company_template_path(company_dir).is_file(), + fields=fields, + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 읽지 못했습니다."}, + ) + + +# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다. +_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024 + + +@router.post("/{project_id}/drawing-export") +async def export_drawing_file(project_id: UUID, request: DrawingExportRequest) -> Response: + """캐드 화면의 도면을 DXF·DWG 파일로 내려보낸다 (2026-09-06 사용자 지시). + + DWG 는 LibreDWG 가 서버에 있을 때만 나간다 — 없으면 「DXF 로 받으라」는 안내로 떨어진다. + """ + try: + data, skipped = await asyncio.to_thread( + export_drawing, request.drawing, request.file_format + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도면 내보내기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도면을 내보내지 못했습니다."}, + ) + name = re.sub(r"[^0-9A-Za-z가-힣_.-]", "_", request.name or "drawing")[:120] or "drawing" + # 한글 파일 이름은 헤더에 그대로 못 싣는다(latin-1) — 옛 브라우저용 영문 이름과 + # UTF-8 이름을 함께 준다. + ascii_name = re.sub(r"[^0-9A-Za-z_.-]", "_", name) or "drawing" + encoded_name = quote(f"{name}.{request.file_format}") + return Response( + content=data, + media_type="application/octet-stream", + headers={ + "Content-Disposition": ( + f'attachment; filename="{ascii_name}.{request.file_format}"; ' + f"filename*=UTF-8''{encoded_name}" + ), + # 그림처럼 못 담은 도형 수 — 화면이 안내 문구를 띄우는 데 쓴다. + "X-Aislo-Skipped": str(sum(skipped.values())), + }, + ) + + +@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse) +async def import_frame_template( + project_id: UUID, file: UploadFile = File(...) +) -> FrameTemplateImportResponse | JSONResponse: + """외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다. + + 아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + """ + try: + data = await file.read() + if len(data) > _FRAME_IMPORT_MAX_BYTES: + raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).") + entities = await asyncio.to_thread(import_frame_file, file.filename or "", data) + validate_template_entities(entities) + return FrameTemplateImportResponse( + project_id=str(project_id), + drawing=frame_document(entities), + entity_count=len(entities), + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 불러오기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각 파일을 읽지 못했습니다."}, + ) + + +@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def put_frame_template( + project_id: UUID, request: FrameTemplateSaveRequest +) -> FrameTemplateSaveResponse | JSONResponse: + """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. + + 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 + 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). + """ + try: + entities = request.drawing.get("entities") + if not isinstance(entities, list): + raise ValueError("도각 엔티티가 없습니다.") + company_dir = await _company_dir(project_id) + await asyncio.to_thread(save_company_template, company_dir, entities) + return FrameTemplateSaveResponse(project_id=str(project_id)) + except (FileNotFoundError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 저장하지 못했습니다."}, + ) + + +@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: + """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). + + 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. + 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. + """ + try: + company_dir = await _company_dir(project_id) + removed = await asyncio.to_thread(clear_company_template, company_dir) + return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."}, + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 53abb805..c129d9fc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field class DesignDrawingItem(BaseModel): @@ -102,6 +102,27 @@ class FrameTemplateResponse(BaseModel): drawing: dict[str, Any] # 회사가 고친 도각을 쓰고 있으면 True, 프로그램 기본 도각이면 False. customized: bool = False + # 자리표에 **보여 줄** 실제 값 (2026-09-06 사용자 지시) — 편집 화면 전용이고 + # 저장값은 `{{키}}` 토큰 그대로다. 값이 없는 자리는 담기지 않는다. + fields: dict[str, str] = {} + + +class FrameTemplateImportResponse(BaseModel): + """외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 바꾼 결과.""" + + status: str = "success" + project_id: str + drawing: dict[str, Any] + entity_count: int + + +class DrawingExportRequest(BaseModel): + """캐드 화면의 도면을 DXF·DWG 파일로 내보내는 요청 (2026-09-06 사용자 지시).""" + + drawing: dict[str, Any] + file_format: str = Field(default="dxf", pattern="^(dxf|dwg)$") + # 내려받을 파일 이름(확장자 제외). 비우면 도면 id 를 쓴다. + name: str | None = Field(default=None, max_length=120) class FrameTemplateSaveRequest(BaseModel): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 7a195985..a27b4cb1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { type CadDrawing, fetchFrameTemplate, + importFrameTemplate, resetFrameTemplate, saveFrameTemplate, } from "./B07_DesignDetail_Api_Fetch"; @@ -27,18 +28,28 @@ export interface FrameTemplateEditor { interface Options { projectId: string; - /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). */ - sendLoad: (drawing: CadDrawing, meta: null) => void; + /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). + * frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */ + sendLoad: ( + drawing: CadDrawing, + meta: null, + frameEdit?: boolean, + frameFields?: Record, + ) => void; /** CAD에서 현재 편집본을 받아온다. */ requestCadDrawing: () => Promise; /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ restoreDrawing: () => void; /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ onSaved: () => void; + /** 지금 보던 도면의 이름·번호 — 자리표 미리보기에 도면명·도면번호로 보여 준다. */ + currentDrawingInfo: () => { label: string; number: string } | null; } export function createFrameTemplateEditor(options: Options): FrameTemplateEditor { let editing = false; + // 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다. + let frameFields: Record = {}; const banner = document.createElement("div"); banner.className = "b07-frame-edit"; @@ -61,6 +72,23 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor variant: "ghost", onClick: () => void resetToDefault(), }); + /** + * 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가 + * 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직 + * 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + */ + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".dxf,.dwg"; + fileInput.hidden = true; + fileInput.addEventListener("change", () => void importFile()); + + const importButton = createButton({ + label: "파일 불러오기", + variant: "ghost", + onClick: () => fileInput.click(), + }); + const cancelButton = createButton({ label: "취소", variant: "ghost", @@ -68,8 +96,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor }); const bannerButtons = document.createElement("div"); bannerButtons.className = "b07-frame-edit__buttons"; - bannerButtons.append(finishButton, resetButton, cancelButton); - banner.append(bannerButtons); + bannerButtons.append(finishButton, importButton, resetButton, cancelButton); + banner.append(bannerButtons, fileInput); const button = createButton({ label: "도각 편집", @@ -84,16 +112,42 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor options.restoreDrawing(); }; + async function importFile(): Promise { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + importButton.disabled = true; + try { + const response = await importFrameTemplate(options.projectId, file); + options.sendLoad(response.drawing, null, true, frameFields); + label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`; + showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", + "error", + ); + } finally { + importButton.disabled = false; + } + } + async function enter(): Promise { try { const response = await fetchFrameTemplate(options.projectId); + // 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다. + const info = options.currentDrawingInfo(); + frameFields = { + ...(response.fields ?? {}), + ...(info ? { 도면명: info.label, 도면번호: info.number } : {}), + }; editing = true; button.disabled = true; banner.hidden = false; label.textContent = response.customized ? "도각 편집 중 — 회사 도각을 고치고 있습니다." : "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다."; - options.sendLoad(response.drawing, null); + options.sendLoad(response.drawing, null, true, frameFields); } catch (error) { showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error"); } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 1615d984..3cb61056 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -35,6 +35,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmDesignDrawing, + exportDrawing, fetchDesignDrawing, fetchDesignDrawingList, invalidateDesignDrawing, @@ -82,6 +83,7 @@ const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed"; const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; +const CAD_EXPORT_MESSAGE = "aislo:b08:export-file"; /** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시). * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; @@ -124,7 +126,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadHost.append(frame, license); let cadReady = false; - let pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null } | undefined; + let pendingLoad: + | { + drawing: CadDrawing; + meta: DesignMeta | null; + frameEdit: boolean; + frameFields: Record; + } + | undefined; let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; @@ -205,11 +214,18 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hasNext: index < drawings.length - 1, }); - const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => { - pendingLoad = { drawing, meta }; + // frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다 + // (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). + const sendLoad = ( + drawing: CadDrawing, + meta: DesignMeta | null, + frameEdit = false, + frameFields: Record = {}, + ) => { + pendingLoad = { drawing, meta, frameEdit, frameFields }; if (!cadReady) return; frame.contentWindow?.postMessage( - { type: CAD_LOAD_MESSAGE, drawing, meta }, + { type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields }, window.location.origin, ); pendingLoad = undefined; @@ -401,6 +417,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } + /** + * 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시). + * 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다. + */ + async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise { + showLoadingOverlay(); + try { + const { drawing } = await requestCadDrawing(); + const name = currentDrawing?.label ?? "도면"; + const result = await exportDrawing(projectId as string, drawing, fileFormat, name); + const link = document.createElement("a"); + link.href = URL.createObjectURL(result.blob); + link.download = `${name}.${fileFormat}`; + link.click(); + URL.revokeObjectURL(link.href); + showToast( + result.skipped > 0 + ? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.` + : `${fileFormat.toUpperCase()} 로 내보냈습니다.`, + "success", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : "도면을 내보내지 못했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } + } + const frameEditor = createFrameTemplateEditor({ projectId: projectId as string, sendLoad, @@ -409,6 +453,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: () => drawingCache.clear(), + currentDrawingInfo: () => + currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); window.addEventListener("message", (event: MessageEvent) => { @@ -419,6 +465,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { drawing?: CadDrawing; quantityTable?: QuantityTable | null; direction?: "prev" | "next"; + fileFormat?: "dxf" | "dwg"; dirty?: boolean; kind?: string; text?: string; @@ -446,7 +493,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); } else if (message.type === CAD_READY_MESSAGE) { cadReady = true; - if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta); + if (pendingLoad) + sendLoad( + pendingLoad.drawing, + pendingLoad.meta, + pendingLoad.frameEdit, + pendingLoad.frameFields, + ); } else if (message.type === CAD_LOADED_MESSAGE) { cadHost.dataset.loading = "false"; } else if (message.type === CAD_ERROR_MESSAGE) { @@ -460,6 +513,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (!frameEditor.isEditing()) cadDirty = message.dirty !== false; } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { navigateDrawing(message.direction); + } else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) { + void exportDrawingFile(message.fileFormat); } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { const resolve = resolveSave; resolveSave = undefined; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 8de0f675..05a5d1fd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -8,10 +8,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import type { - CrossDesignInfo, - DesignDrawingItem, -} from "./B07_DesignDetail_Api_Fetch"; +import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -69,10 +66,7 @@ export function buildDrawingSidePanel( return panel; } - const drawingButton = ( - drawing: DesignDrawingItem, - label: string, - ): HTMLButtonElement => { + const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; @@ -91,9 +85,7 @@ export function buildDrawingSidePanel( ? drawings.filter((item) => item.kind === group.kind) : group.idPrefix ? drawings.filter( - (item) => - item.id === group.idPrefix || - item.id.startsWith(`${group.idPrefix}_`), + (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), ) : drawings.filter((item) => item.id === group.blankId); // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. @@ -117,8 +109,7 @@ export function buildDrawingSidePanel( const button = drawingButton(drawing, group.label); // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") - button.title = "준비 중 — 도각만 표시합니다"; + if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } @@ -140,10 +131,7 @@ export function buildDrawingSidePanel( return panel; } -const GROUND_TYPE_LABEL: Record< - CrossDesignInfo["ground_type"], - keyof typeof ui_locales -> = { +const GROUND_TYPE_LABEL: Record = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", @@ -165,8 +153,7 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; - if (!ditch || ditch.type === "none" || design.ditch_enabled === false) - return "없음"; + if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; if (ditch.type === "l_type") return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; @@ -202,23 +189,19 @@ export function buildDesignInfoPanel( const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); - const scopeLabel = - scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); + const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); stationName.textContent = `${scopeLabel} ${title}`; const confirmed = design?.status === "confirmed"; const badge = document.createElement("span"); badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; - badge.textContent = confirmed - ? L("B07_Info_Confirmed") - : L("B07_Info_Provisional"); + badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; - empty.textContent = - scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); + empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); panel.append(empty); return panel; } @@ -233,9 +216,7 @@ export function buildDesignInfoPanel( infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow( L("B07_Info_DitchSide"), - design.ditch_side === "left" - ? L("B06_Design_Ditch_Left") - : L("B06_Design_Ditch_Right"), + design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), ), ); @@ -245,10 +226,7 @@ export function buildDesignInfoPanel( planTitle.textContent = L("B07_Info_Plan_Title"); plan.append( planTitle, - infoRow( - L("B07_Info_DesignElevation"), - `${design.design_elevation_m.toFixed(2)}m`, - ), + infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index f69191bf..9d3fb9cb 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -942,3 +942,77 @@ body > canvas[data-id="canvas"] { color: var(--cad-text-dim); font-size: 11px; } + +/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로 + 부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */ +.cad-frame-tokens { + position: fixed; + /* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */ + right: 12px; + bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px); + z-index: 3; + max-height: 46vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + width: 236px; + padding: 8px; + border: 1px solid var(--cad-line); + border-radius: 6px; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); + color: var(--cad-text); +} + +.cad-frame-tokens__header { + color: var(--cad-text-dim); + font-size: 11px; + line-height: 1.4; +} + +.cad-frame-tokens__group { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.cad-frame-tokens__title { + width: 100%; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__button { + padding: 3px 7px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; + cursor: pointer; +} + +.cad-frame-tokens__button:hover { + background: var(--cad-accent-soft, var(--cad-chrome-raised)); +} + +/* 자리표 칸 크기 입력 (2026-09-06) */ +.cad-frame-tokens__size { + display: flex; + align-items: center; + gap: 4px; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__size input { + width: 64px; + padding: 2px 4px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; +} diff --git a/B07_DesignDetail/openwebcad/src/App.tsx b/B07_DesignDetail/openwebcad/src/App.tsx index d5f6c178..93ce68b1 100644 --- a/B07_DesignDetail/openwebcad/src/App.tsx +++ b/B07_DesignDetail/openwebcad/src/App.tsx @@ -1,5 +1,6 @@ import './App.css'; import { ToastContainer } from 'react-toastify'; +import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx'; import { QuantityPanel } from './components/QuantityPanel.tsx'; import { Toolbar } from './components/Toolbar.tsx'; @@ -8,6 +9,7 @@ function App() {
+
); diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index a1bc7986..c401ee26 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -3,6 +3,7 @@ import { toast } from 'react-toastify'; import { clearRecovery, restoreRecovery } from '../helpers/autosave'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; +import { requestDrawingExport } from '../integration/aislo-drawing-bridge'; import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; import { redo, undo } from '../state'; @@ -44,6 +45,26 @@ export const FILE_COMMANDS: CadCommand[] = [ return 'JSON 내보내기'; }, }, + { + id: 'EXPORTDXF', + label: 'DXF 내보내기', + glyph: '📐', + hint: '지금 도면을 DXF 파일로 내려받는다', + run: () => { + requestDrawingExport('dxf'); + return 'DXF 내보내기'; + }, + }, + { + id: 'EXPORTDWG', + label: 'DWG 내보내기', + glyph: '📁', + hint: '지금 도면을 DWG 파일로 내려받는다 (서버에 변환기가 있을 때)', + run: () => { + requestDrawingExport('dwg'); + return 'DWG 내보내기'; + }, + }, { id: 'EXPORTSVG', label: 'SVG 내보내기', diff --git a/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx new file mode 100644 index 00000000..c98ff97c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx @@ -0,0 +1,195 @@ +import { Point } from '@flatten-js/core'; +import { type FC, useCallback, useEffect, useState } from 'react'; +import { HtmlEvent } from '../App.types'; +import { ImageEntity } from '../entities/ImageEntity'; +import { TextEntity } from '../entities/TextEntity'; +import { + getActiveLayerId, + getEntities, + getFrameFields, + getScreenCanvasDrawController, + getSelectedEntities, + isFrameEditMode, + setEntities, +} from '../state'; + +/** + * 도각 자리표 패널 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정). + * + * 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 도면 출력 때 서버의 + * 치환 엔진이 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다. + * + * 도각 편집으로 도면을 실었을 때만 뜬다. 놓은 자리표는 화면 한가운데에 서고, 그 뒤 + * 캐드의 이동·크기 도구로 자리를 잡는다. + */ + +/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */ +const TEXT_TOKENS = [ + '도면명', + '도면번호', + '공사명', + '위치', + '시행청', + '용역회사', + '연도기번', + '사업량', + '과업책임자', + '분야별책임자', + '설계자', + '설계일자', + '축척_A1', + '축척_A3', +] as const; + +/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 도면에서 그림째 빠진다. */ +const IMAGE_TOKENS = ['회사로고', '과업책임자서명', '분야별책임자서명', '설계자서명'] as const; + +const TEXT_SIZE_MM = 5; +// 자리표가 차지하는 칸 기본 크기(mm). 놓은 뒤 아래 「칸 크기」에서 고친다. +const TEXT_BOX_WIDTH_MM = 60; +const TEXT_BOX_HEIGHT_MM = 10; +const IMAGE_WIDTH_MM = 32; +const IMAGE_HEIGHT_MM = 16; + +/** 지금 보고 있는 화면의 한가운데 (도면 좌표). 자리표가 처음 서는 자리다. */ +function viewCenter(): Point { + const drawController = getScreenCanvasDrawController(); + const size = drawController.getCanvasSize(); + return drawController.targetToWorld(new Point(size.x / 2, size.y / 2)); +} + +function addTextPlaceholder(token: string): void { + const center = viewCenter(); + const entity = new TextEntity(getActiveLayerId(), `{{${token}}}`, center, { + fontSize: TEXT_SIZE_MM, + textAlign: 'center', + boxWidth: TEXT_BOX_WIDTH_MM, + boxHeight: TEXT_BOX_HEIGHT_MM, + }); + // 편집 중에는 실제 값을 보여 준다 — 저장값은 토큰 그대로다. + entity.previewLabel = getFrameFields()[token] ?? null; + setEntities([...getEntities(), entity], true); +} + +async function addImagePlaceholder(token: string): Promise { + const center = viewCenter(); + const halfWidth = IMAGE_WIDTH_MM / 2; + const halfHeight = IMAGE_HEIGHT_MM / 2; + const points = [ + { x: center.x - halfWidth, y: center.y - halfHeight }, + { x: center.x + halfWidth, y: center.y - halfHeight }, + { x: center.x + halfWidth, y: center.y + halfHeight }, + { x: center.x - halfWidth, y: center.y + halfHeight }, + ]; + // 자리표는 그림 주소가 아니라 토큰이라 fromJson 으로 만든다 — 그래야 원본 문자열이 + // 그대로 보존돼 저장 한 번에 주소로 굳지 않는다. + const entity = await ImageEntity.fromJson({ + id: crypto.randomUUID(), + type: 'Image', + lineColor: '#f5f7fa', + lineWidth: 1, + layerId: getActiveLayerId(), + shapeData: { points, imageData: `{{${token}}}` }, + } as Parameters[0]); + const preview = getFrameFields()[token]; + if (preview) entity.setPreviewImage(preview); + setEntities([...getEntities(), entity], true); +} + +/** 지금 고른 자리표 하나 — 칸 크기를 고칠 대상. 없으면 null. */ +function selectedPlaceholder(): TextEntity | ImageEntity | null { + const selected = getSelectedEntities(); + if (selected.length !== 1) return null; + const entity = selected[0]; + if (entity instanceof TextEntity && entity.getLabel().includes('{{')) return entity; + if (entity instanceof ImageEntity && entity.isPlaceholder()) return entity; + return null; +} + +function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: number } { + const box = entity.getBoundingBox(); + return { width: Math.round(box.width * 10) / 10, height: Math.round(box.height * 10) / 10 }; +} + +export const FramePlaceholderPanel: FC = () => { + const [visible, setVisible] = useState(isFrameEditMode()); + const [picked, setPicked] = useState(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const refresh = useCallback(() => { + setVisible(isFrameEditMode()); + const entity = selectedPlaceholder(); + setPicked(entity); + if (entity) setSize(boxSizeOf(entity)); + }, []); + useEffect(() => { + window.addEventListener(HtmlEvent.UPDATE_STATE, refresh); + return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh); + }, [refresh]); + + const applySize = (width: number, height: number): void => { + if (!picked) return; + setSize({ width, height }); + picked.setBoxSize(width, height); + setEntities([...getEntities()], true); + }; + + if (!visible) return null; + + return ( +
+
+ 자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다 +
+
+ 글자 + {TEXT_TOKENS.map((token) => ( + + ))} +
+
+ 그림 (칸에 비율 그대로 들어감) + {IMAGE_TOKENS.map((token) => ( + + ))} +
+ {picked && ( +
+ 고른 자리표 칸 크기 (mm) + + +
+ )} +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index cca181dd..7606d0ef 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -1,7 +1,7 @@ import type * as Flatten from '@flatten-js/core'; import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core'; import { type Shape, type SnapPoint, SnapPointType } from '../App.types'; -import type { DrawController } from '../drawControllers/DrawController.ts'; +import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController.ts'; import { twoPointBoxToPolygon } from '../helpers/box-to-polygon'; import { getExportColor } from '../helpers/get-export-color'; import { mirrorAngleOverAxis } from '../helpers/mirror-angle-over-axis.ts'; @@ -33,6 +33,22 @@ export class ImageEntity implements Entity { * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. */ private sourceData: string | null = null; + /** 저장값(그림 주소 또는 자리표 토큰). */ + public getSourceData(): string | null { + return this.sourceData; + } + + /** 자리표인가 — `{{회사로고}}` 처럼 토큰을 들고 있는 그림. */ + public isPlaceholder(): boolean { + return (this.sourceData ?? '').includes('{{'); + } + + /** 도각 편집에서만 쓰는 보여 주기용 그림. 저장값(sourceData)은 토큰 그대로 둔다. */ + public setPreviewImage(dataUrl: string): void { + const image = new Image(); + image.src = dataUrl; + this.imageElement = image; + } constructor( layerId: string, @@ -69,28 +85,69 @@ export class ImageEntity implements Entity { this.lineWidth, this.lineDash ); - // 테두리는 **집었을 때만** 그린다. 늘 그리면 도각의 로고·서명 자리에 흰 사각형이 - // 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02). - if (highlighted || selected) { + // 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서 + // 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다 + // (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다. + const placeholder = this.isPlaceholder() && !this.imageElement.src; + // 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰 + // 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02). + if (highlighted || selected || placeholder) { for (const edge of polygonToSegments(this.polygon)) { drawController.drawLine(edge.start, edge.end); } } - const width = this.polygon.box.width; - const height = this.polygon.box.height; + if (placeholder) { + // 아직 보여 줄 그림이 없으면 이름표만 남긴다. + drawController.drawText(this.sourceData ?? '', this.polygon.box.center, { + ...DEFAULT_TEXT_OPTIONS, + textAlign: 'center', + fontSize: Math.max(this.polygon.box.height / 4, 2), + textColor: this.lineColor, + }); + return; // 그림이 없으니 그릴 것도 없다 + } + + // 칸 안에 **비율을 지켜** 넣는다 (2026-09-06 사용자 지시) — 칸을 늘렸다고 그림이 + // 늘어나면 로고·서명이 찌그러진다. 남는 자리는 비운다(가운데 맞춤). + const boxWidth = this.polygon.box.width; + const boxHeight = this.polygon.box.height; + const naturalWidth = this.imageElement.naturalWidth || boxWidth; + const naturalHeight = this.imageElement.naturalHeight || boxHeight; + const fit = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight); + const width = naturalWidth * fit; + const height = naturalHeight * fit; // Draw image drawController.drawImage( this.imageElement, - this.polygon.box.xmin, - this.polygon.box.ymin, + this.polygon.box.xmin + (boxWidth - width) / 2, + this.polygon.box.ymin + (boxHeight - height) / 2, width, height, this.angle ); } + /** 자리표 칸 크기(mm)를 바꾼다. 가운데는 그대로 두고 네 귀만 다시 잡는다. */ + public setBoxSize(width: number, height: number): void { + const center = this.polygon.box.center; + const halfWidth = Math.max(width, 1) / 2; + const halfHeight = Math.max(height, 1) / 2; + this.polygon = twoPointBoxToPolygon( + new Point(center.x - halfWidth, center.y - halfHeight), + new Point(center.x + halfWidth, center.y + halfHeight) + ); + } + + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.polygon = twoPointBoxToPolygon( + new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)), + new Point(Math.max(a.x, b.x), Math.max(a.y, b.y)) + ); + } + public move(x: number, y: number) { this.polygon = this.polygon.translate(new Vector(x, y)); } diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 312209a2..6479e156 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -8,6 +8,9 @@ import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../stat import { type Entity, EntityName, type JsonEntity } from './Entity'; import type { LineEntity } from './LineEntity.ts'; +/** 글자를 집었을 때 두르는 외곽선 색 — 도면 선과 헷갈리지 않게 회색. */ +const TEXT_SELECTION_OUTLINE_COLOR = '#9aa0a6'; + export interface TextOptions { textDirection: Vector; textAlign: 'left' | 'center' | 'right'; @@ -17,6 +20,13 @@ export interface TextOptions { /** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */ bold?: boolean; italic?: boolean; + /** + * 도각 자리표의 칸 크기(mm). 있으면 `basePoint` 가 **칸의 한가운데**이고 글자는 + * 가로·세로 가운데 맞춤으로 그려진다 (2026-09-06 사용자 지시). 없으면 예전처럼 + * 글자 하나로만 산다. + */ + boxWidth?: number; + boxHeight?: number; } export class TextEntity implements Entity { @@ -29,6 +39,12 @@ export class TextEntity implements Entity { public opacity?: number; /** GROUP으로 묶인 객체가 공유하는 식별자 */ public groupId?: string; + /** + * 도각 편집에서만 쓰는 **보여 주기용 값** (2026-09-06 사용자 지시). 자리표 + * `{{공사명}}` 대신 실제 공사명을 그려 사용자가 어디에 무엇이 들어가는지 알게 한다. + * 저장값(`label`)은 토큰 그대로다 — 값이 아니라 연결을 저장한다. + */ + public previewLabel: string | null = null; private readonly options: TextOptions; constructor( @@ -49,14 +65,26 @@ export class TextEntity implements Entity { parentHighlighted?: boolean, parentSelected?: boolean ): void { - drawController.setLineStyles( - parentHighlighted ?? isEntityHighlighted(this), - parentSelected ?? isEntitySelected(this), - this.lineColor, - this.lineWidth, - this.lineDash - ); - drawController.drawText(this.label, this.basePoint, this.options); + const highlighted = parentHighlighted ?? isEntityHighlighted(this); + const selected = parentSelected ?? isEntitySelected(this); + drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash); + drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options); + // 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이 + // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 + // 없어 이 선이 실리지 않는다. + if (highlighted || selected) { + const box = this.getBoundingBox(); + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]); + for (let index = 0; index < corners.length; index++) { + drawController.drawLine(corners[index], corners[(index + 1) % corners.length]); + } + } } public move(x: number, y: number) { @@ -82,12 +110,16 @@ export class TextEntity implements Entity { } public clone(): TextEntity { - return new TextEntity( + const copy = new TextEntity( getActiveLayerId(), this.label, this.basePoint.clone(), cloneDeep(this.options) ); + // 보여 주기용 값도 함께 옮긴다 — 안 옮기면 그립을 옮긴 순간 자리표가 다시 + // `{{도면명}}` 으로 보인다(2026-09-06 실측). + copy.previewLabel = this.previewLabel; + return copy; } public intersectsWithBox(box: Box): boolean { @@ -99,6 +131,16 @@ export class TextEntity implements Entity { } public getBoundingBox(): Box { + const { boxWidth, boxHeight } = this.options; + if (boxWidth && boxHeight) { + // 자리표는 칸이 곧 경계다 — basePoint 가 칸 한가운데다. + return new Box( + this.basePoint.x - boxWidth / 2, + this.basePoint.y - boxHeight / 2, + this.basePoint.x + boxWidth / 2, + this.basePoint.y + boxHeight / 2 + ); + } // TODO find better way of determining the text bounding box return new Box( this.basePoint.x, @@ -108,6 +150,23 @@ export class TextEntity implements Entity { ); } + /** 자리표 칸 크기(mm)를 바꾼다. 글자 크기는 그대로 둔다. */ + public setBoxSize(width: number, height: number): void { + this.options.boxWidth = Math.max(width, 1); + this.options.boxHeight = Math.max(height, 1); + } + + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.setBoxSize(Math.abs(b.x - a.x), Math.abs(b.y - a.y)); + this.basePoint = new Point((a.x + b.x) / 2, (a.y + b.y) / 2); + } + + /** 자리표 칸이 있는가 — 칸이 있으면 basePoint 가 칸 한가운데다. */ + public hasBox(): boolean { + return Boolean(this.options.boxWidth && this.options.boxHeight); + } + public getTextOptions(): TextOptions { return this.options; } @@ -181,6 +240,8 @@ export class TextEntity implements Entity { fontFamily: this.options.fontFamily, bold: this.options.bold, italic: this.options.italic, + boxWidth: this.options.boxWidth, + boxHeight: this.options.boxHeight, }, }, }; @@ -205,6 +266,8 @@ export class TextEntity implements Entity { fontFamily: jsonEntity.shapeData.options.fontFamily, bold: jsonEntity.shapeData.options.bold, italic: jsonEntity.shapeData.options.italic, + boxWidth: jsonEntity.shapeData.options.boxWidth, + boxHeight: jsonEntity.shapeData.options.boxHeight, } ); textEntity.id = jsonEntity.id; @@ -226,5 +289,8 @@ export interface TextJsonData { fontFamily: string; bold?: boolean; italic?: boolean; + /** 도각 자리표 칸 크기(mm) — basePoint 가 칸 한가운데다. */ + boxWidth?: number; + boxHeight?: number; }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/grips.ts b/B07_DesignDetail/openwebcad/src/helpers/grips.ts index ccfb3dda..3f00c182 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/grips.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/grips.ts @@ -2,11 +2,13 @@ * 그립 — 선택한 객체에 붙는 편집점. 집어서 다음 클릭 위치로 옮긴다. * 형상 필드가 전부 private이라 좌표를 고칠 때는 공개 생성자로 같은 객체를 다시 만들어 * 배열에서 바꿔 끼운다(id는 그대로 둬서 선택·그룹이 유지된다). - * ponytail: 호·해치·이미지·치수는 그립을 만들지 않는다 — 각각 각도·경계·비율·연관 규칙이 - * 따로 있어 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * ponytail: 호·해치·치수는 그립을 만들지 않는다 — 각각 각도·경계·연관 규칙이 따로 있어 + * 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * 그림과 「칸이 있는 글자」는 네 모서리를 끌어 **칸 크기**를 바꾼다 (2026-09-06 사용자 지시). */ import { type Circle, Point, type Polygon, type Segment } from '@flatten-js/core'; import { CircleEntity } from '../entities/CircleEntity'; +import { ImageEntity } from '../entities/ImageEntity'; import type { Entity } from '../entities/Entity'; import { LineEntity } from '../entities/LineEntity'; import { PointEntity } from '../entities/PointEntity'; @@ -124,6 +126,20 @@ export function getGrips(entity: Entity): Grip[] { } return grips; } + // 칸이 있는 글자·그림 — 네 모서리로 칸을 늘이고 줄인다. 가운데 그립은 옮기기. + if ( + (entity instanceof TextEntity && entity.hasBox()) || + entity instanceof ImageEntity + ) { + const box = entity.getBoundingBox(); + return [ + { point: new Point(box.xmin, box.ymin), kind: 'vertex', index: 0 }, + { point: new Point(box.xmax, box.ymin), kind: 'vertex', index: 1 }, + { point: new Point(box.xmax, box.ymax), kind: 'vertex', index: 2 }, + { point: new Point(box.xmin, box.ymax), kind: 'vertex', index: 3 }, + { point: new Point(box.center.x, box.center.y), kind: 'base', index: 0 }, + ]; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const point = entity.getFirstPoint(); return point ? [{ point, kind: 'base', index: 0 }] : []; @@ -188,6 +204,23 @@ export function applyGrip(entity: Entity, grip: Grip, target: Point): Entity | n copy.setRowHeight(grip.index, top - target.y); return copy; } + // 칸이 있는 글자·그림 — 모서리를 끌면 마주 보는 모서리를 붙박아 칸을 다시 잡는다. + if ((entity instanceof TextEntity && entity.hasBox()) || entity instanceof ImageEntity) { + const box = entity.getBoundingBox(); + if (grip.kind === 'base') { + return moveCopy(entity, target.x - box.center.x, target.y - box.center.y); + } + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + const opposite = corners[(grip.index + 2) % corners.length]; + const copy = inherit(entity, entity.clone()) as TextEntity | ImageEntity; + copy.setBoxFromCorners(target, opposite); + return copy; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const base = entity.getFirstPoint(); if (!base) return null; diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index e5d135b1..9f6cd23c 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,5 +1,6 @@ import { Point } from '@flatten-js/core'; import { type DesignMeta, HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; import { TextEntity } from '../entities/TextEntity.ts'; import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; @@ -9,6 +10,7 @@ import { getCanvas, getDesignMeta, getEntities, + getFrameFields, getLayers, getScreenCanvasDrawController, isDrawingDirty, @@ -17,11 +19,13 @@ import { setActiveLayerId, setDesignMeta, setEntities, + setFrameEditMode, setLayers, } from '../state.ts'; import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; +import { registerBoxResizeDrag } from './box-resize-drag.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -31,11 +35,16 @@ export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed'; export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request'; export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response'; export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate'; +export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file'; interface DrawingLoadMessage { type: typeof AISLO_DRAWING_LOAD_MESSAGE; drawing: JsonDrawingFileSerialized; meta?: DesignMeta | null; + /** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */ + frameEdit?: boolean; + /** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */ + frameFields?: Record; } interface DrawingSaveRequestMessage { @@ -58,6 +67,14 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') { notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction }); } +/** + * 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시). + * 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다. + */ +export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') { + notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat }); +} + /** * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 @@ -113,6 +130,27 @@ function registerTextDoubleClickEdit() { }); } +/** + * 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다 + * (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적. + */ +function applyFramePreview(): void { + const fields = getFrameFields(); + if (Object.keys(fields).length === 0) return; + const token = /^\{\{(.+)\}\}$/; + for (const entity of getEntities()) { + if (entity instanceof TextEntity) { + const match = token.exec(entity.getLabel().trim()); + const value = match ? fields[match[1]] : undefined; + entity.previewLabel = value ?? null; + } else if (entity instanceof ImageEntity && entity.isPlaceholder()) { + const match = token.exec((entity.getSourceData() ?? '').trim()); + const value = match ? fields[match[1]] : undefined; + if (value) entity.setPreviewImage(value); + } + } +} + /** * B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다. * DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다. @@ -148,6 +186,8 @@ export function registerAisloDrawingBridge() { resetUndoBaseline(); // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); + setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); + if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: // 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중 @@ -168,6 +208,7 @@ export function registerAisloDrawingBridge() { notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() }); }); registerTextDoubleClickEdit(); + registerBoxResizeDrag(); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts new file mode 100644 index 00000000..f2700dfa --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts @@ -0,0 +1,119 @@ +import { Point } from '@flatten-js/core'; +import { HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; +import { TextEntity } from '../entities/TextEntity.ts'; +import { + getCanvas, + getEntities, + getScreenCanvasDrawController, + getSelectedEntities, + isDrawingReadOnly, + setEntities, +} from '../state.ts'; + +/** + * 칸 모서리를 **끌어서** 크기를 바꾼다 (2026-09-06 사용자 지시). + * + * 대상은 「칸이 있는 글자(도각 자리표)」와 「그림」이다. 캐드 본래의 그립은 집었다 놓는 + * 방식이라 도각 칸을 맞출 때 손이 많이 갔다 — 끌기는 여기서 따로 받는다. + * + * 그리기 도구와 부딪히지 않게 **모서리를 집었을 때만** 이벤트를 가로챈다(그 밖에는 그대로 + * 흘려보낸다). 확정한 도면은 읽기 전용이라 손대지 않는다. + */ + +/** 모서리를 집었다고 볼 화면 거리(px). 그립 크기(8px)보다 조금 넉넉하게 잡는다. */ +const GRAB_PIXELS = 9; + +type Resizable = TextEntity | ImageEntity; + +function resizableSelection(): Resizable | null { + const selected = getSelectedEntities(); + if (selected.length !== 1) return null; + const entity = selected[0]; + if (entity instanceof TextEntity && entity.hasBox()) return entity; + if (entity instanceof ImageEntity) return entity; + return null; +} + +/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */ +function worldAt(event: MouseEvent): Point | null { + const canvas = getCanvas(); + if (!canvas) return null; + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + return getScreenCanvasDrawController().targetToWorld(screenPoint); +} + +function corners(entity: Resizable): Point[] { + const box = entity.getBoundingBox(); + return [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; +} + +let dragging: { entity: Resizable; opposite: Point } | null = null; + +export function registerBoxResizeDrag(): void { + const canvas = getCanvas(); + if (!canvas) return; + + // 캡처 단계에서 먼저 받는다 — 모서리를 집은 경우에만 선택 도구로 넘어가지 않게 막는다. + window.addEventListener( + 'mousedown', + (event: MouseEvent) => { + if (event.button !== 0 || event.target !== canvas || isDrawingReadOnly()) return; + const entity = resizableSelection(); + const world = entity ? worldAt(event) : null; + if (!entity || !world) return; + const scale = getScreenCanvasDrawController().getScreenScale(); + const grabDistance = GRAB_PIXELS / scale; + const points = corners(entity); + let index = -1; + let best = grabDistance; + points.forEach((corner, seq) => { + const distance = corner.distanceTo(world)[0]; + if (distance <= best) { + best = distance; + index = seq; + } + }); + if (index < 0) return; + dragging = { entity, opposite: points[(index + 2) % points.length] }; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); + + window.addEventListener( + 'mousemove', + (event: MouseEvent) => { + if (!dragging) return; + const world = worldAt(event); + if (!world) return; + dragging.entity.setBoxFromCorners(world, dragging.opposite); + // 끄는 동안에는 되돌리기 스택에 쌓지 않는다 — 손을 뗄 때 한 번만 쌓는다. + setEntities([...getEntities()], false); + // 자리표 패널의 칸 크기 숫자도 따라 움직이게 알린다. + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + }, + true + ); + + window.addEventListener( + 'mouseup', + (event: MouseEvent) => { + if (!dragging) return; + dragging = null; + setEntities([...getEntities()], true); + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); +} diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts index 487f2955..0ca203cd 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -198,7 +198,7 @@ export const RIBBON_TABS: RibbonTab[] = [ { label: '내보내기', big: ['EXPORT'], - commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'], + commands: ['EXPORTDXF', 'EXPORTDWG', 'EXPORTSVG', 'EXPORTPNG', 'QSAVE'], }, ], }, diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index d8b7d70d..1fcf3217 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -186,6 +186,10 @@ let snapTrackingEnabled = true; * 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다. */ let designMeta: DesignMeta | null = null; +/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */ +let frameEditMode = false; +/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ +let frameFields: Record = {}; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -256,6 +260,8 @@ export const getSnapEnabled = () => snapEnabled; export const getGridEnabled = () => gridEnabled; export const getSnapTrackingEnabled = () => snapTrackingEnabled; export const getDesignMeta = (): DesignMeta | null => designMeta; +export const isFrameEditMode = (): boolean => frameEditMode; +export const getFrameFields = (): Record => frameFields; export const isDrawingDirty = () => drawingDirty; /** * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 @@ -486,6 +492,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; triggerReactUpdate(StateVariable.designMeta); }; +/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ +export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { + frameEditMode = enabled; + frameFields = enabled ? fields : {}; + notifyWindow(HtmlEvent.UPDATE_STATE); +}; // 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정). // 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은 // 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다. diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/COPYING b/B07_DesignDetail/openwebcad/tools/libredwg/COPYING new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/B07_DesignDetail/openwebcad/tools/libredwg/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/README.md b/B07_DesignDetail/openwebcad/tools/libredwg/README.md new file mode 100644 index 00000000..cabd38ea --- /dev/null +++ b/B07_DesignDetail/openwebcad/tools/libredwg/README.md @@ -0,0 +1,37 @@ +# LibreDWG (DWG ↔ DXF 변환기) — 동봉본 + +B07 도각·도면의 **DWG 불러오기·내보내기**에 쓰는 외부 프로그램. Aislo 코드가 아니라 +GNU LibreDWG 프로젝트의 산출물을 그대로 담아 둔 것이다 (2026-09-06 사용자 확정). + +| 항목 | 값 | +|---|---| +| 버전 | 0.14.8594 (Windows 64비트 배포본) | +| 원본 | | +| 프로젝트 | | +| 라이선스 | GNU GPL v3 이상 (`COPYING`) | +| 읽는 범위 | DWG r13 ~ r2018. 그보다 새 형식은 화면이 「2018 DWG 또는 DXF 로 저장」 안내로 떨어진다 | + +## 담은 파일 + +`dwg2dxf.exe`(DWG→DXF) · `dxf2dwg.exe`(DXF→DWG) · `libredwg-0.dll` · +`libiconv-2.dll` · `libpcre2-8-0.dll` · `libpcre2-16-0.dll` + +배포본의 나머지(예제·문서·헤더·**파이썬 바인딩**)는 담지 않았다. 특히 파이썬 바인딩은 +**일부러 뺐다** — 파이썬에서 `import` 하면 라이브러리를 끌어안는 것이라 GPL 이 Aislo +코드까지 번진다. + +## 지키는 선 + +- Aislo 는 이 프로그램들을 **별도 실행 파일로만 부른다**(`subprocess`). 링크하거나 + 라이브러리로 품지 않는다 — 그래서 Aislo 코드에는 GPL 의무가 미치지 않는다. +- 프로그램을 고객에게 넘길 때는 이 폴더(실행 파일 + `COPYING` + 위 원본 주소)를 함께 + 넘긴다. 원본 소스는 위 주소에서 그대로 받을 수 있다. +- 이 파일들은 **고치지 않는다**. 새 버전으로 바꿀 때는 원본 배포본에서 같은 6개만 다시 + 담고 이 문서의 버전을 고친다. + +## 찾는 자리 + +서버 코드가 이 폴더를 먼저 본다. 다른 자리에 두려면 `.env` 에 경로를 적는다. + +- `LIBREDWG_DWG2DXF_PATH` — DWG 불러오기 +- `LIBREDWG_DXF2DWG_PATH` — DWG 내보내기 diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe b/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe new file mode 100644 index 00000000..c6935077 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe b/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe new file mode 100644 index 00000000..11c3cf80 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll new file mode 100644 index 00000000..3cace95e Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll new file mode 100644 index 00000000..aab05da2 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll new file mode 100644 index 00000000..25941164 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll new file mode 100644 index 00000000..a8ac2e12 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll differ diff --git a/db_management/016_project_members.sql b/db_management/016_project_members.sql new file mode 100644 index 00000000..6f58721c --- /dev/null +++ b/db_management/016_project_members.sql @@ -0,0 +1,26 @@ +-- 016_project_members.sql +-- 프로젝트 참여자 (2026-09-06 사용자 확정) +-- +-- 도면 표제란에 실리는 이름은 한 사람뿐이지만(과업책임자·분야별책임자·설계자), 설계 +-- 과정에서 손을 대는 보조 인원은 여러 명일 수 있다. 그 사람들을 담는 표다. +-- +-- 참여자는 일반 사용자여도 그 프로젝트를 **수정할 수 있다**. 만든 사람은 등록 시점에 +-- 자동으로 참여자가 된다. + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id', + user_id INT NOT NULL COMMENT 'users.id', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + KEY idx_project_members_user (user_id), + CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='프로젝트 참여자'; + +-- 기존 프로젝트는 만든 사람을 참여자로 채워 둔다. +INSERT IGNORE INTO project_members (project_id, user_id) +SELECT id, user_id FROM projects WHERE deleted_at IS NULL; diff --git a/main.py b/main.py index af402284..f667060e 100644 --- a/main.py +++ b/main.py @@ -49,6 +49,7 @@ from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router +from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from common_util.common_util_auth import ( require_company, @@ -278,6 +279,16 @@ async def lifespan(app: FastAPI): await cursor.execute( "UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),) ) + # 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다 + # (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다. + await cursor.execute( + """UPDATE users u + JOIN users a ON a.email = %s AND a.deleted_at IS NULL + AND a.company_id IS NOT NULL + SET u.role = 'SYSTEM_ADMIN' + WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""", + (ADMIN_EMAIL.lower(),), + ) await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) @@ -329,9 +340,25 @@ logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_D # B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist") + + +class _NoCacheHtmlStatic(StaticFiles): + """`index.html` 만 캐시하지 않는다 (2026-09-06). + + 캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로** + 남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다). + """ + + def file_response(self, *args, **kwargs): # type: ignore[override] + response = super().file_response(*args, **kwargs) + if str(getattr(response, "path", "")).endswith(".html"): + response.headers["Cache-Control"] = "no-store" + return response + + app.mount( "/b07-cad", - StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), + _NoCacheHtmlStatic(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), name="b07-cad", ) logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}") @@ -395,6 +422,7 @@ app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) +app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index 5ccc8f9c..d0b83d57 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1,1352 +1,1400 @@ { - "format": 6, - "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", - "entities": [ - { - "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면명}}", - "basePoint": { - "x": 728.9614, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면번호}}", - "basePoint": { - "x": 794.15392, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{공사명}}", - "basePoint": { - "x": 172.993229, - "y": 26.184925 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.884559874108106, - "y": 566.9570935802498 - }, - "endPoint": { - "x": 811.8687906157556, - "y": 566.9570935802498 - } - } - }, - { - "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ + "format": 6, + "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", + "entities": [ { - "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 566.957094 + "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면명}}", + "basePoint": { + "x": 728.9113785811373, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 22.0 + } } - } }, { - "id": "e490918e-af37-5fe6-abf9-37919db79b53", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 566.957094 + "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면번호}}", + "basePoint": { + "x": 792.890084790497, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 22.0 + } } - } }, { - "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 16.957051 + "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{공사명}}", + "basePoint": { + "x": 116.86879080885097, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 22.0 + } } - } }, { - "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 16.957051 + "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.884559874108106, + "y": 566.9570935802498 + }, + "endPoint": { + "x": 811.8687906157556, + "y": 566.9570935802498 + } } - } - } - ] - }, - { - "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868790618179396, - "y": 38.95705092559213 - }, - "endPoint": { - "x": 811.8845598717162, - "y": 38.95705092559213 - } - } - }, - { - "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 191.868791, - "y": 46.957051 - } - } }, { - "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 311.868791, - "y": 46.957051 - } - } + "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 566.957094 + } + } + }, + { + "id": "e490918e-af37-5fe6-abf9-37919db79b53", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 566.957094 + } + } + }, + { + "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 16.957051 + } + } + }, + { + "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 16.957051 + } + } + } + ] }, { - "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 431.868791, - "y": 46.957051 + "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868790618179396, + "y": 38.95705092559213 + }, + "endPoint": { + "x": 811.8845598717162, + "y": 38.95705092559213 + } } - } }, { - "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 481.868791, - "y": 46.957051 - } - } + "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 191.868791, + "y": 46.957051 + } + } + }, + { + "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 311.868791, + "y": 46.957051 + } + } + }, + { + "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 431.868791, + "y": 46.957051 + } + } + }, + { + "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 481.868791, + "y": 46.957051 + } + } + }, + { + "id": "b4af8196-f773-5019-acd8-47ec851924e0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 531.868791, + "y": 46.957051 + } + } + }, + { + "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 581.868791, + "y": 46.957051 + } + } + }, + { + "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 581.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 631.868791, + "y": 46.957051 + } + } + }, + { + "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 631.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 681.868791, + "y": 46.957051 + } + } + }, + { + "id": "72edfb24-244a-58a0-b73d-61515cfa2260", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 681.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 771.868791, + "y": 46.957051 + } + } + }, + { + "id": "97fe4eec-8e51-570c-a55f-609112757912", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 771.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 811.88456, + "y": 46.957051 + } + } + } + ] }, { - "id": "b4af8196-f773-5019-acd8-47ec851924e0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 531.868791, - "y": 46.957051 + "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "공 사 명", + "basePoint": { + "x": 116.86879080885097, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 8.0 + } } - } }, { - "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 581.868791, - "y": 46.957051 + "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "시 행 청", + "basePoint": { + "x": 251.8687906175109, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 581.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 631.868791, - "y": 46.957051 + "id": "0294bb9d-5715-590c-8f92-0023891244f1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.86879061770193, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 191.86879061770193, + "y": 16.9570509256849 + } } - } }, { - "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 631.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 681.868791, - "y": 46.957051 + "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.8687906173199, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 311.8687906173199, + "y": 16.9570509256849 + } } - } }, { - "id": "72edfb24-244a-58a0-b73d-61515cfa2260", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 681.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 771.868791, - "y": 46.957051 + "id": "e545c507-25b7-5cc4-a203-22cb016cb568", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 42.9570509255885 + } } - } }, { - "id": "97fe4eec-8e51-570c-a55f-609112757912", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 771.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 811.88456, - "y": 46.957051 + "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "축 척", + "basePoint": { + "x": 456.8687906168583, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "공 사 명", - "basePoint": { - "x": 63.850772, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "시 행 청", - "basePoint": { - "x": 206.931974, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0294bb9d-5715-590c-8f92-0023891244f1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.86879061770193, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 191.86879061770193, - "y": 16.9570509256849 - } - } - }, - { - "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.8687906173199, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 311.8687906173199, - "y": 16.9570509256849 - } - } - }, - { - "id": "e545c507-25b7-5cc4-a203-22cb016cb568", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 42.9570509255885 - } - } - }, - { - "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "축 척", - "basePoint": { - "x": 440.340788, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "용 역 회 사", - "basePoint": { - "x": 328.367088, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.86879061693793, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 431.86879061693793, - "y": 16.9570509256849 - } - } - }, - { - "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계 일 자", - "basePoint": { - "x": 485.229485, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.8687906167787, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 481.8687906167787, - "y": 16.9570509256849 - } - } - }, - { - "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 531.8687906166197, - "y": 16.9570509256849 - } - } - }, - { - "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 책 임 자", - "basePoint": { - "x": 541.919243, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 참 여 자", - "basePoint": { - "x": 576.555284, - "y": 43.614023 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 633.9113785814397, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 633.9113785814397, - "y": 16.9570509256849 - } - } - }, - { - "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 583.9113785815989, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 583.9113785815989, - "y": 16.9570509256849 - } - } - }, - { - "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "분 야 별 책 임 자", - "basePoint": { - "x": 592.856439, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "192fbaba-663c-5621-b4be-8fd026441fc6", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 683.9113785812806, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 16.9570509256849 - } - } - }, - { - "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계", - "basePoint": { - "x": 644.130922, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 명", - "basePoint": { - "x": 696.657727, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 773.9113785809941, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 773.9113785809941, - "y": 16.9570509256849 - } - } - }, - { - "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 번 호", - "basePoint": { - "x": 777.837719, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "405c6bca-5474-5c97-ba3b-f050bb937028", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{분야별책임자}}", - "basePoint": { - "x": 602.801356, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{과업책임자}}", - "basePoint": { - "x": 551.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계자}}", - "basePoint": { - "x": 651.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계일자}}", - "basePoint": { - "x": 507.61607, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{용역회사}}", - "basePoint": { - "x": 380.474721, - "y": 28.814653 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "89422706-3f58-52af-8730-5d535e4b1d72", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": -5.042928 - } - } }, { - "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": 588.957072 + "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "용 역 회 사", + "basePoint": { + "x": 371.8687906171289, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": 588.957072 + "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.86879061693793, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 431.86879061693793, + "y": 16.9570509256849 + } } - } }, { - "id": "4e989434-b862-5526-b497-1e4e77f999a1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": -5.042928 + "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계 일 자", + "basePoint": { + "x": 506.8687906166992, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{시행청}}", - "basePoint": { - "x": 263.627137, - "y": 28.814653 }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A1 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 31.289636 + { + "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.8687906167787, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 481.8687906167787, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A3 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 25.208603 + { + "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 531.8687906166197, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A1}}", - "basePoint": { - "x": 462.443604, - "y": 29.323307 + { + "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 책 임 자", + "basePoint": { + "x": 557.8900845991093, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A3}}", - "basePoint": { - "x": 462.443604, - "y": 23.242274 + { + "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 참 여 자", + "basePoint": { + "x": 607.8900845989501, + "y": 44.95705096279425 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 152.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" + { + "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 633.9113785814397, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 633.9113785814397, + "y": 16.9570509256849 + } + } + }, + { + "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 583.9113785815989, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 583.9113785815989, + "y": 16.9570509256849 + } + } + }, + { + "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "분 야 별 책 임 자", + "basePoint": { + "x": 608.9113785815193, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "192fbaba-663c-5621-b4be-8fd026441fc6", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 683.9113785812806, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 16.9570509256849 + } + } + }, + { + "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계", + "basePoint": { + "x": 658.9113785813602, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 명", + "basePoint": { + "x": 728.9113785811373, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 8.0 + } + } + }, + { + "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 773.9113785809941, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 773.9113785809941, + "y": 16.9570509256849 + } + } + }, + { + "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 번 호", + "basePoint": { + "x": 792.890084790497, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 8.0 + } + } + }, + { + "id": "405c6bca-5474-5c97-ba3b-f050bb937028", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{분야별책임자}}", + "basePoint": { + "x": 608.9113785815193, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{과업책임자}}", + "basePoint": { + "x": 557.8900845991093, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 22.0 + } + } + }, + { + "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계자}}", + "basePoint": { + "x": 658.9113785813602, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계일자}}", + "basePoint": { + "x": 506.8687906166992, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{용역회사}}", + "basePoint": { + "x": 371.8687906171289, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "89422706-3f58-52af-8730-5d535e4b1d72", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": -5.042928 + } + } + }, + { + "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": 588.957072 + } + } + }, + { + "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": 588.957072 + } + } + }, + { + "id": "4e989434-b862-5526-b497-1e4e77f999a1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": -5.042928 + } + } + } + ] + }, + { + "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{시행청}}", + "basePoint": { + "x": 251.8687906175109, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A1 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A3 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A1}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A3}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 316.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 36.0 + }, + { + "x": 316.0, + "y": 36.0 + } + ], + "imageData": "{{회사로고}}" + } + }, + { + "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 638.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 25.5 + }, + { + "x": 638.0, + "y": 25.5 + } + ], + "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": "{{분야별책임자서명}}" + } } - } - }, - { - "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 316.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 36.0 - }, - { - "x": 316.0, - "y": 36.0 - } - ], - "imageData": "{{회사로고}}" - } - }, - { - "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 638.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 25.5 - }, - { - "x": 638.0, - "y": 25.5 - } - ], - "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": [ - { - "id": "-00.기본BOX TEXT", - "name": "-00.기본BOX TEXT", - "isVisible": true, - "isLocked": false - }, - { - "id": "-00.기본BOX", - "name": "-00.기본BOX", - "isVisible": true, - "isLocked": false - } - ] -} + ], + "layers": [ + { + "id": "-00.기본BOX TEXT", + "name": "-00.기본BOX TEXT", + "isVisible": true, + "isLocked": false + }, + { + "id": "-00.기본BOX", + "name": "-00.기본BOX", + "isVisible": true, + "isLocked": false + } + ] +} \ No newline at end of file