Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-09-06 16:20:55 +09:00
53 changed files with 4927 additions and 1969 deletions
+3
View File
@@ -0,0 +1,3 @@
# 동봉한 LibreDWG 실행 파일 — 줄바꿈 변환이 닿으면 실행이 깨진다.
B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary
B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary
+48 -6
View File
@@ -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<unknown> {
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<unknown> {
return request(`/dashboard/company${companyQuery(companyId)}`, {
method: "PUT",
body: body(payload),
});
}
/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */
export async function searchMemberCandidates(query: string): Promise<Member[]> {
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<unknown> {
return request("/dashboard/admin/members/invite", {
method: "POST",
body: body({ email, name: name || null }),
});
}
/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */
export function deleteDashboardUser(userId: number): Promise<unknown> {
return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" });
}
/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */
export function setCompanyLogo(
logoAssetId: number | null,
+59
View File
@@ -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)
+63 -199
View File
@@ -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()
@@ -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())
@@ -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
+190 -34
View File
@@ -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"<p>{safe_name}님, {safe_company} 에서 Aislo 팀원으로 등록하려 합니다.</p>"
f"<p>아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.</p>"
f'<p><a href="{APP_PUBLIC_BASE_URL}">{APP_PUBLIC_BASE_URL}</a></p>',
)
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,
+11 -4
View File
@@ -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)
+17 -2
View File
@@ -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),
@@ -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;
+65
View File
@@ -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,
}),
};
}
+20
View File
@@ -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,
);
+8 -7
View File
@@ -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 {
@@ -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<void>;
} {
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<void> => {
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 };
}
+281 -122
View File
@@ -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<void> {
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);
});
}
+4 -1
View File
@@ -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;
}
+5 -31
View File
@@ -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");
+43
View File
@@ -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);
}
@@ -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)
+69 -55
View File
@@ -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<void> {
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);
}
@@ -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;
}
+55 -27
View File
@@ -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<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
@@ -134,17 +128,14 @@ async function requestJson<T>(
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<DesignDrawingListResponse> {
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`);
}
@@ -152,9 +143,7 @@ export function fetchDesignDrawing(
projectId: string,
drawingId: string,
): Promise<DesignDrawingResponse> {
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<void> {
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
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<string, string>;
}
export function fetchFrameTemplate(
projectId: string,
): Promise<FrameTemplateResponse> {
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
return requestJson(`/projects/${projectId}/frame-template`);
}
export function saveFrameTemplate(
projectId: string,
drawing: CadDrawing,
): Promise<void> {
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
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<void> {
return requestJson(`/projects/${projectId}/frame-template`, {
@@ -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 로만 내보낼 수 있습니다.")
@@ -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
@@ -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}],
}
+2 -77
View File
@@ -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": "기본 도각으로 되돌리지 못했습니다."},
)
@@ -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": "기본 도각으로 되돌리지 못했습니다."},
)
+22 -1
View File
@@ -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):
@@ -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<string, string>,
) => void;
/** CAD에서 현재 편집본을 받아온다. */
requestCadDrawing: () => Promise<CadDrawing>;
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
restoreDrawing: () => void;
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
onSaved: () => void;
/** 지금 보던 도면의 이름·번호 — 자리표 미리보기에 도면명·도면번호로 보여 준다. */
currentDrawingInfo: () => { label: string; number: string } | null;
}
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
let editing = false;
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
let frameFields: Record<string, string> = {};
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<void> {
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<void> {
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");
}
+60 -5
View File
@@ -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<void> {
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<string, string>;
}
| undefined;
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
@@ -205,11 +214,18 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
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<string, string> = {},
) => {
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<void> {
}
}
/**
* DXF·DWG (2026-09-06 ).
* .
*/
async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise<void> {
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<void> {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
},
onSaved: () => drawingCache.clear(),
currentDrawingInfo: () =>
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
window.addEventListener("message", (event: MessageEvent<unknown>) => {
@@ -419,6 +465,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
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<void> {
);
} 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<void> {
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;
+11 -33
View File
@@ -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<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
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`),
+74
View File
@@ -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;
}
+2
View File
@@ -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() {
<div className="cad-app">
<Toolbar />
<QuantityPanel />
<FramePlaceholderPanel />
<ToastContainer position="bottom-right" theme="light" />
</div>
);
@@ -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 내보내기',
@@ -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<void> {
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<typeof ImageEntity.fromJson>[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<TextEntity | ImageEntity | null>(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 (
<section className="cad-frame-tokens controls">
<header className="cad-frame-tokens__header">
,
</header>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title"></span>
{TEXT_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => addTextPlaceholder(token)}
>
{token}
</button>
))}
</div>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title"> ( )</span>
{IMAGE_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => void addImagePlaceholder(token)}
>
{token}
</button>
))}
</div>
{picked && (
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title"> (mm)</span>
<label className="cad-frame-tokens__size">
<input
type="number"
min={1}
value={size.width}
onChange={(event) => applySize(Number(event.target.value), size.height)}
/>
</label>
<label className="cad-frame-tokens__size">
<input
type="number"
min={1}
value={size.height}
onChange={(event) => applySize(size.width, Number(event.target.value))}
/>
</label>
</div>
)}
</section>
);
};
@@ -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));
}
@@ -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;
};
}
@@ -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;
@@ -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<string, string>;
}
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);
}
@@ -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
);
}
@@ -198,7 +198,7 @@ export const RIBBON_TABS: RibbonTab[] = [
{
label: '내보내기',
big: ['EXPORT'],
commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'],
commands: ['EXPORTDXF', 'EXPORTDWG', 'EXPORTSVG', 'EXPORTPNG', 'QSAVE'],
},
],
},
+12
View File
@@ -186,6 +186,10 @@ let snapTrackingEnabled = true;
* ··· . null이면 .
*/
let designMeta: DesignMeta | null = null;
/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */
let frameEditMode = false;
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
let frameFields: Record<string, string> = {};
/**
* .
@@ -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<string, string> => 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<string, string> = {}) => {
frameEditMode = enabled;
frameFields = enabled ? fields : {};
notifyWindow(HtmlEvent.UPDATE_STATE);
};
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
@@ -0,0 +1,37 @@
# LibreDWG (DWG ↔ DXF 변환기) — 동봉본
B07 도각·도면의 **DWG 불러오기·내보내기**에 쓰는 외부 프로그램. Aislo 코드가 아니라
GNU LibreDWG 프로젝트의 산출물을 그대로 담아 둔 것이다 (2026-09-06 사용자 확정).
| 항목 | 값 |
|---|---|
| 버전 | 0.14.8594 (Windows 64비트 배포본) |
| 원본 | <https://github.com/LibreDWG/libredwg/releases> |
| 프로젝트 | <https://www.gnu.org/software/libredwg/> |
| 라이선스 | 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 내보내기
+26
View File
@@ -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;
+29 -1
View File
@@ -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)
File diff suppressed because it is too large Load Diff