feat(B01): 회사 정보 수정 화면·중복 조회·주소 지도 미리보기
- 회사 정보 수정 API(PUT /dashboard/company) 및 수정 모달 신설, 회사 패널·회사 관리 표에 수정 진입점 추가 - 회사 등록·수정 입력칸 공용화 및 순서 조정 (사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소) - 회사 등록 시 사업자번호·회사명으로 기존 회사 조회, 있으면 가입 신청으로 유도 - 주소 지도 미리보기 추가 — VWorld 주소검색·배경지도 타일을 서버가 중계(키 노출 방지), 지도 라이브러리 없이 타일 3x3 조합 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -306,6 +306,27 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
|||||||
export const companyAssetFileUrl = (assetId: number): string =>
|
export const companyAssetFileUrl = (assetId: number): string =>
|
||||||
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
`${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 사용자 확정). */
|
/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */
|
||||||
export async function searchMemberCandidates(query: string): Promise<Member[]> {
|
export async function searchMemberCandidates(query: string): Promise<Member[]> {
|
||||||
const data = await request<{ users: Member[] }>(
|
const data = await request<{ users: Member[] }>(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -332,6 +332,28 @@ async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
|
|||||||
return await cursor.fetchone()
|
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]]:
|
async def search_companies(query: str) -> list[dict[str, Any]]:
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
pattern = f"%{query}%"
|
pattern = f"%{query}%"
|
||||||
|
|||||||
@@ -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 common_util.common_util_storage import read_stored_asset
|
||||||
from config.config_system import APP_PUBLIC_BASE_URL, 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 (
|
from .B01_Dashboard_Repository import (
|
||||||
assign_user_company,
|
assign_user_company,
|
||||||
change_user_role,
|
change_user_role,
|
||||||
@@ -35,6 +36,7 @@ from .B01_Dashboard_Repository import (
|
|||||||
soft_delete_project,
|
soft_delete_project,
|
||||||
soft_delete_user,
|
soft_delete_user,
|
||||||
update_admin_user,
|
update_admin_user,
|
||||||
|
update_company,
|
||||||
update_project,
|
update_project,
|
||||||
update_user_profile,
|
update_user_profile,
|
||||||
)
|
)
|
||||||
@@ -182,6 +184,47 @@ async def user_company_create(
|
|||||||
return {"status": "success", **result}
|
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")
|
@router.post("/user/company/join")
|
||||||
async def user_company_join(
|
async def user_company_join(
|
||||||
payload: JoinCompanyRequest,
|
payload: JoinCompanyRequest,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicke
|
|||||||
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
|
||||||
import {
|
import {
|
||||||
openChangeRoleModal,
|
openChangeRoleModal,
|
||||||
|
openEditCompanyModal,
|
||||||
openCreateCompanyModal,
|
openCreateCompanyModal,
|
||||||
openDeleteUserModal,
|
openDeleteUserModal,
|
||||||
openEditUserModal,
|
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_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_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 사용자 확정).
|
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
|
||||||
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
|
||||||
if (state.user.role !== "USER") {
|
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_Field_BusinessNumber"),
|
||||||
L("B01_Dashboard_Table_Status"),
|
L("B01_Dashboard_Table_Status"),
|
||||||
"로고",
|
"로고",
|
||||||
|
L("B01_Dashboard_Table_Action"),
|
||||||
],
|
],
|
||||||
companies.map((company) => [
|
companies.map((company) => [
|
||||||
text(company.name),
|
text(company.name),
|
||||||
text(company.business_registration_number),
|
text(company.business_registration_number),
|
||||||
text(company.business_status),
|
text(company.business_status),
|
||||||
logoCell(company),
|
logoCell(company),
|
||||||
|
user
|
||||||
|
? createButton({
|
||||||
|
label: L("Common_Btn_Edit"),
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => openEditCompanyModal(company),
|
||||||
|
})
|
||||||
|
: text(""),
|
||||||
]),
|
]),
|
||||||
DASHBOARD_VISIBLE_ROWS,
|
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 };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
updateDashboardUser,
|
updateDashboardUser,
|
||||||
removeCompanyMember,
|
removeCompanyMember,
|
||||||
createCompany,
|
createCompany,
|
||||||
|
updateCompany,
|
||||||
joinCompany,
|
joinCompany,
|
||||||
searchCompanies,
|
searchCompanies,
|
||||||
addCompanyMember,
|
addCompanyMember,
|
||||||
@@ -26,11 +27,13 @@ import {
|
|||||||
updateCompanyAsset,
|
updateCompanyAsset,
|
||||||
createCompanyAsset,
|
createCompanyAsset,
|
||||||
setCompanyLogo,
|
setCompanyLogo,
|
||||||
|
type CompanyInfo,
|
||||||
type DashboardUser,
|
type DashboardUser,
|
||||||
type ProjectItem,
|
type ProjectItem,
|
||||||
type Member,
|
type Member,
|
||||||
} from "./B01_Dashboard_Api_Fetch";
|
} from "./B01_Dashboard_Api_Fetch";
|
||||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||||
|
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
||||||
import {
|
import {
|
||||||
attachModalDismiss,
|
attachModalDismiss,
|
||||||
buildUserFields,
|
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({
|
const number = createInputField({
|
||||||
label: L("B01_Dashboard_Field_BusinessNumber"),
|
label: L("B01_Dashboard_Field_BusinessNumber"),
|
||||||
|
value: company?.business_registration_number ?? "",
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
|
const name = createInputField({
|
||||||
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
|
label: L("B01_Dashboard_Table_Company"),
|
||||||
// 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다.
|
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 이하)" });
|
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
|
||||||
logo.input.type = "file";
|
logo.input.type = "file";
|
||||||
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
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(
|
openModal(
|
||||||
L("B01_Dashboard_Modal_CreateCompany"),
|
L("B01_Dashboard_Modal_CreateCompany"),
|
||||||
[name.root, number.root, address.root, owner.root, logo.root],
|
[...fields.rows, findBtn, matches],
|
||||||
async () => {
|
async () => {
|
||||||
if (!name.input.value.trim() || !number.input.value.trim()) return;
|
if (!fields.valid()) return;
|
||||||
const created = await createCompany({
|
const values = fields.values();
|
||||||
name: name.input.value.trim(),
|
const created = await createCompany(values);
|
||||||
business_registration_number: number.input.value.trim(),
|
const file = fields.logoFile();
|
||||||
business_address: address.input.value.trim() || null,
|
|
||||||
business_owner: owner.input.value.trim() || null,
|
|
||||||
});
|
|
||||||
const file = logo.input.files?.[0];
|
|
||||||
if (file && created?.company_id) {
|
if (file && created?.company_id) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("kind", "LOGO");
|
form.append("kind", "LOGO");
|
||||||
form.append("label", `${name.input.value.trim()} 로고`);
|
form.append("label", `${values.name} 로고`);
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
form.append("company_id", String(created.company_id));
|
form.append("company_id", String(created.company_id));
|
||||||
const assetId = await createCompanyAsset(form);
|
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 {
|
export function openFindCompanyModal(): void {
|
||||||
const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true });
|
const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true });
|
||||||
const results = document.createElement("div");
|
const results = document.createElement("div");
|
||||||
|
|||||||
@@ -273,3 +273,31 @@
|
|||||||
grid-template-columns: 1fr;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user