feat(B01): 회사 정보 수정 화면·중복 조회·주소 지도 미리보기

- 회사 정보 수정 API(PUT /dashboard/company) 및 수정 모달 신설, 회사 패널·회사 관리 표에 수정 진입점 추가
- 회사 등록·수정 입력칸 공용화 및 순서 조정 (사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소)
- 회사 등록 시 사업자번호·회사명으로 기존 회사 조회, 있으면 가입 신청으로 유도
- 주소 지도 미리보기 추가 — VWorld 주소검색·배경지도 타일을 서버가 중계(키 노출 방지), 지도 라이브러리 없이 타일 3x3 조합

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:29:00 +09:00
co-authored by Claude Opus 5
parent 2adb5507a4
commit bca78ee6bd
8 changed files with 389 additions and 15 deletions
+21
View File
@@ -306,6 +306,27 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
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<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[] }>(
+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)
+22
View File
@@ -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}%"
+43
View File
@@ -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,
+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,
);
@@ -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 };
}
+124 -15
View File
@@ -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");
+28
View File
@@ -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);
}