From bca78ee6bd17640305df100befd89c647f98e9f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 11:29:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B01):=20=ED=9A=8C=EC=82=AC=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=C2=B7?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회사 정보 수정 API(PUT /dashboard/company) 및 수정 모달 신설, 회사 패널·회사 관리 표에 수정 진입점 추가 - 회사 등록·수정 입력칸 공용화 및 순서 조정 (사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소) - 회사 등록 시 사업자번호·회사명으로 기존 회사 조회, 있으면 가입 신청으로 유도 - 주소 지도 미리보기 추가 — VWorld 주소검색·배경지도 타일을 서버가 중계(키 노출 방지), 지도 라이브러리 없이 타일 3x3 조합 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 21 +++ B01_Dashboard/B01_Dashboard_Map.py | 59 ++++++++ B01_Dashboard/B01_Dashboard_Repository.py | 22 +++ B01_Dashboard/B01_Dashboard_Router.py | 43 ++++++ B01_Dashboard/B01_Dashboard_UI_Company.ts | 20 +++ B01_Dashboard/B01_Dashboard_UI_MapPreview.ts | 72 ++++++++++ B01_Dashboard/B01_Dashboard_UI_Modals.ts | 139 +++++++++++++++++-- B01_Dashboard/B01_Dashboard_UI_Style.css | 28 ++++ 8 files changed, 389 insertions(+), 15 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_Map.py create mode 100644 B01_Dashboard/B01_Dashboard_UI_MapPreview.ts diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 448b4f80..d262992f 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -306,6 +306,27 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; +/** 주소를 좌표로 바꾼다 (회사 주소 지도 미리보기). */ +export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number }> { + return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`); +} + +/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */ +export function updateCompany( + payload: { + name: string; + business_registration_number: string; + business_address: string | null; + business_owner: string | null; + }, + companyId?: number | null, +): Promise { + return request(`/dashboard/company${companyQuery(companyId)}`, { + method: "PUT", + body: body(payload), + }); +} + /** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */ export async function searchMemberCandidates(query: string): Promise { const data = await request<{ users: Member[] }>( diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py new file mode 100644 index 00000000..6fbcf33c --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -0,0 +1,59 @@ +"""회사 주소 지도 — VWorld 주소검색·배경지도 타일 (2026-09-06 사용자 지시). + +이미 쓰던 VWorld 키를 그대로 쓴다. 키가 화면으로 새지 않게 타일도 서버가 받아 넘긴다. +""" + +from __future__ import annotations + +import asyncio +import json +import urllib.parse +import urllib.request +from typing import Any + +from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY + +_GEOCODE_URL = "https://api.vworld.kr/req/address" +_TILE_URL = "http://api.vworld.kr/req/wmts/1.0.0/{key}/Base/{z}/{y}/{x}.png" + + +def _fetch(url: str, timeout: int = 5) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def _geocode_sync(address: str) -> dict[str, Any] | None: + """도로명으로 먼저 찾고, 없으면 지번으로 다시 찾는다.""" + for address_type in ("ROAD", "PARCEL"): + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getcoord", + "version": "2.0", + "crs": "epsg:4326", + "address": address, + "refine": "true", + "simple": "false", + "format": "json", + "type": address_type, + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + continue + point = (body.get("response") or {}).get("result", {}).get("point") + if point: + return {"lon": float(point["x"]), "lat": float(point["y"])} + return None + + +async def geocode_address(address: str) -> dict[str, Any] | None: + return await asyncio.to_thread(_geocode_sync, address.strip()) + + +async def fetch_base_tile(z: int, x: int, y: int) -> bytes: + url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x) + return await asyncio.to_thread(_fetch, url) diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index d20e76ea..35aac465 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -332,6 +332,28 @@ async def get_user_company(company_id: int | None) -> dict[str, Any] | None: 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 + await connection.commit() + return changed + + async def search_companies(query: str) -> list[dict[str, Any]]: pool = get_db_pool() pattern = f"%{query}%" diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index a62e5b48..c12a3126 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -12,6 +12,7 @@ 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 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, @@ -35,6 +36,7 @@ from .B01_Dashboard_Repository import ( soft_delete_project, soft_delete_user, update_admin_user, + update_company, update_project, update_user_profile, ) @@ -182,6 +184,47 @@ 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), +): + """회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다.""" + if not await update_company(_scope_company(session, company_id), payload.model_dump()): + 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, + x: int, + y: int, + session: dict[str, Any] = Depends(verify_session), +): + """배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다.""" + _ = session + 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, diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 1da33720..b6a7af21 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -12,6 +12,7 @@ import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicke import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, + openEditCompanyModal, openCreateCompanyModal, openDeleteUserModal, openEditUserModal, @@ -44,6 +45,17 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement { text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), ); + // 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다. + if (state.user.role !== "USER" && state.company) { + const company = state.company; + wrap.append( + createButton({ + label: "회사 정보 수정", + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }), + ); + } // 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정). // 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다. if (state.user.role !== "USER") { @@ -173,12 +185,20 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), "로고", + L("B01_Dashboard_Table_Action"), ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), logoCell(company), + user + ? createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }) + : text(""), ]), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts new file mode 100644 index 00000000..d87137b6 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -0,0 +1,72 @@ +import { API_BASE_URL } from "@config/config_frontend"; +import { geocodeAddress } from "./B01_Dashboard_Api_Fetch"; + +/** + * 주소 지도 미리보기 (2026-09-06 사용자 지시). + * + * 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다. + * 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다. + */ +const ZOOM = 15; +const TILE = 256; +const GRID = 3; + +function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } { + const n = 2 ** ZOOM; + const rad = (lat * Math.PI) / 180; + const fx = ((lon + 180) / 360) * n; + const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n; + return { x: Math.floor(fx), y: Math.floor(fy), dx: fx - Math.floor(fx), dy: fy - Math.floor(fy) }; +} + +export function buildAddressMap(): { + root: HTMLElement; + show: (address: string) => Promise; +} { + const root = document.createElement("div"); + root.className = "b01-dashboard__map"; + const note = document.createElement("p"); + note.className = "b01-dashboard__modal-text"; + note.textContent = "주소를 넣고 「지도 확인」을 누르십시오."; + root.append(note); + + const show = async (address: string): Promise => { + root.innerHTML = ""; + if (!address.trim()) { + note.textContent = "주소를 먼저 입력하십시오."; + root.append(note); + return; + } + const point = await geocodeAddress(address).catch(() => null); + if (!point) { + note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."; + root.append(note); + return; + } + const center = tileIndex(point.lat, point.lon); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__map-grid"; + const half = Math.floor(GRID / 2); + for (let row = -half; row <= half; row += 1) { + for (let col = -half; col <= half; col += 1) { + const img = document.createElement("img"); + img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`; + img.width = TILE; + img.height = TILE; + img.alt = ""; + grid.append(img); + } + } + const marker = document.createElement("span"); + marker.className = "b01-dashboard__map-marker"; + // 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다. + marker.style.left = `${(half + center.dx) * TILE * 0.5}px`; + marker.style.top = `${(half + center.dy) * TILE * 0.5}px`; + const frame = document.createElement("div"); + frame.className = "b01-dashboard__map-frame"; + frame.append(grid, marker); + root.append(frame); + }; + + return { root, show }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index b98a2d15..f24fd237 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -14,6 +14,7 @@ import { updateDashboardUser, removeCompanyMember, createCompany, + updateCompany, joinCompany, searchCompanies, addCompanyMember, @@ -26,11 +27,13 @@ import { updateCompanyAsset, createCompanyAsset, setCompanyLogo, + type CompanyInfo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview"; import { attachModalDismiss, buildUserFields, @@ -383,35 +386,120 @@ export function openDeleteUserModal(target: Member | DashboardUser): void { }); } -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); @@ -422,6 +510,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"); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index de0a5d3f..98dc66fd 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -273,3 +273,31 @@ 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); +}