feat(b01): 회사 주소를 검색해서 고르는 칸으로 교체
손으로 정확히 쳐야만 되던 주소칸을 후보 검색·선택 방식으로 바꿈. VWorld 주소검색을 서버가 중계하고, 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 그림. 못 찾는 주소는 「직접 입력」으로 옛 방식을 씀. - 회사 목록 SQL 이 business_address·business_owner 를 안 가져와 수정 모달이 비어 열리고 저장 시 빈 값으로 덮어쓰던 것 수정 - 지도 기본 배율 15 → 17, +·- 단계 조절 추가 - 라벨·입력·버튼을 한 줄에 눕혀 줄 어긋남 해소 - 모달 안 휠이 뒤 대시보드로 새던 것 차단 - 회사 패널에 주소 한 줄 표시 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
This commit is contained in:
@@ -316,6 +316,25 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
|
||||
return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`);
|
||||
}
|
||||
|
||||
/** 주소 후보 한 줄 — 검색해서 골라 넣는 용도. */
|
||||
export interface AddressCandidate {
|
||||
zipcode: string;
|
||||
road: string;
|
||||
parcel: string;
|
||||
building: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** 주소를 검색해 후보를 받는다 (도로명 먼저, 없으면 지번). */
|
||||
export async function searchAddress(query: string): Promise<AddressCandidate[]> {
|
||||
const body = await request<{ items?: AddressCandidate[] }>(
|
||||
`/dashboard/company/address/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
return body.items ?? [];
|
||||
}
|
||||
|
||||
/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */
|
||||
export function updateCompany(
|
||||
payload: {
|
||||
|
||||
@@ -57,3 +57,60 @@ async def geocode_address(address: str) -> dict[str, Any] | None:
|
||||
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)
|
||||
|
||||
|
||||
_SEARCH_URL = "https://api.vworld.kr/req/search"
|
||||
|
||||
|
||||
def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
|
||||
"""도로명으로 먼저 찾고, 비면 지번으로 다시 찾는다 — 후보를 목록으로 돌려준다."""
|
||||
for category in ("road", "parcel"):
|
||||
params = urllib.parse.urlencode(
|
||||
{
|
||||
"service": "search",
|
||||
"request": "search",
|
||||
"version": "2.0",
|
||||
"crs": "EPSG:4326",
|
||||
"size": str(size),
|
||||
"page": "1",
|
||||
"query": query,
|
||||
"type": "address",
|
||||
"category": category,
|
||||
"format": "json",
|
||||
"errorformat": "json",
|
||||
"key": VWORLD_API_KEY,
|
||||
}
|
||||
)
|
||||
try:
|
||||
body = json.loads(_fetch(f"{_SEARCH_URL}?{params}", timeout=8).decode("utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
result = (body.get("response") or {}).get("result") or {}
|
||||
items = result.get("items") or []
|
||||
found: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
address = item.get("address") or {}
|
||||
point = item.get("point") or {}
|
||||
try:
|
||||
lon = float(point["x"])
|
||||
lat = float(point["y"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"zipcode": address.get("zipcode") or "",
|
||||
"road": address.get("road") or "",
|
||||
"parcel": address.get("parcel") or "",
|
||||
"building": address.get("bldnm") or "",
|
||||
"category": category,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
}
|
||||
)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
|
||||
return await asyncio.to_thread(_search_sync, query.strip(), size)
|
||||
|
||||
@@ -254,6 +254,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
|
||||
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.business_address, c.business_owner,
|
||||
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
|
||||
@@ -25,7 +25,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_Map import fetch_base_tile, geocode_address, search_address
|
||||
from .B01_Dashboard_Repository import (
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
@@ -257,6 +257,16 @@ async def company_geocode(
|
||||
return {"status": "success", **point}
|
||||
|
||||
|
||||
@router.get("/company/address/search")
|
||||
async def company_address_search(
|
||||
query: str = Query(min_length=2, max_length=200),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
"""주소 후보를 찾아 준다 — 회사 등록·수정 화면에서 골라 넣는 용도."""
|
||||
_ = session
|
||||
return {"status": "success", "items": await search_address(query)}
|
||||
|
||||
|
||||
@router.get("/map/tile/{z}/{x}/{y}")
|
||||
async def map_tile(
|
||||
z: int = Path(ge=0, le=19),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { searchAddress, type AddressCandidate } from "./B01_Dashboard_Api_Fetch";
|
||||
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/**
|
||||
* 회사 주소 입력칸 — 검색해서 고르는 방식 (2026-09-13 사용자 지시).
|
||||
*
|
||||
* 손으로 정확히 치지 않아도 되게, 일부만 넣어 후보를 받고 고른다.
|
||||
* 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 바로 그린다.
|
||||
* 저장은 지금대로 한 줄 — 「기본주소 + 상세주소」. 우편번호는 화면 확인용.
|
||||
*
|
||||
* 줄 배치는 라벨·입력·버튼을 한 줄에 세운다(B06 횡단 설정과 같은 꼴) — ui-field 가
|
||||
* 기본이 세로라 그대로 두면 오류 슬롯 높이만큼 버튼이 밀려 수평이 어긋난다.
|
||||
* 그래서 오류 슬롯은 접고, 잘못 넣은 것은 토스트로 알린다.
|
||||
*/
|
||||
|
||||
export interface AddressFieldHandle {
|
||||
root: HTMLElement;
|
||||
/** 저장할 한 줄 주소. 비면 null. */
|
||||
value: () => string | null;
|
||||
}
|
||||
|
||||
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
||||
function candidateLine(item: AddressCandidate): string {
|
||||
const main = item.category === "parcel" ? item.parcel : item.road;
|
||||
return main || item.road || item.parcel;
|
||||
}
|
||||
|
||||
function candidateHint(item: AddressCandidate): string {
|
||||
const other = item.category === "parcel" ? item.road : item.parcel;
|
||||
return [item.building, other, item.zipcode].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** createButton 은 글자를 span 에 담는다 — textContent 로 덮으면 그 껍질이 사라진다. */
|
||||
function setButtonLabel(button: HTMLButtonElement, label: string): void {
|
||||
const slot = button.querySelector(".ui-btn__label");
|
||||
if (slot) slot.textContent = label;
|
||||
else button.textContent = label;
|
||||
}
|
||||
|
||||
function row(...parts: HTMLElement[]): HTMLElement {
|
||||
const line = document.createElement("div");
|
||||
line.className = "b01-dashboard__address-row";
|
||||
line.append(...parts);
|
||||
return line;
|
||||
}
|
||||
|
||||
export function buildAddressField(initial: string | null): AddressFieldHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b01-dashboard__address";
|
||||
|
||||
// 1) 검색 줄 — 일부만 넣어도 후보가 나온다.
|
||||
const query = createInputField({
|
||||
label: L("B01_Dashboard_Field_Address"),
|
||||
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
|
||||
});
|
||||
const findBtn = createButton({
|
||||
label: "주소 찾기",
|
||||
variant: "ghost",
|
||||
onClick: () => void runSearch(),
|
||||
});
|
||||
const manualBtn = createButton({
|
||||
label: "직접 입력",
|
||||
variant: "ghost",
|
||||
onClick: function onB01_Address_Manual_Click() {
|
||||
manual = !manual;
|
||||
setButtonLabel(manualBtn, manual ? "검색으로" : "직접 입력");
|
||||
base.input.readOnly = !manual;
|
||||
query.root.hidden = manual;
|
||||
findBtn.hidden = manual;
|
||||
mapBtn.hidden = !manual;
|
||||
if (manual) base.input.focus();
|
||||
},
|
||||
});
|
||||
const searchRow = row(query.root, findBtn, manualBtn);
|
||||
|
||||
// 2) 후보 목록 — 고르면 사라진다.
|
||||
const list = document.createElement("div");
|
||||
list.className = "b01-dashboard__address-list";
|
||||
list.hidden = true;
|
||||
|
||||
// 3) 고른 주소 + 상세주소.
|
||||
const base = createInputField({ label: "기본주소", value: initial ?? "" });
|
||||
base.input.readOnly = true;
|
||||
const zip = document.createElement("span");
|
||||
zip.className = "b01-dashboard__address-zip";
|
||||
zip.hidden = true;
|
||||
const detail = createInputField({ label: "상세주소", placeholder: "동·층·호" });
|
||||
|
||||
// 4) 지도 — 고른 즉시 그린다. 직접 입력일 때만 버튼으로 확인한다.
|
||||
const map = buildAddressMap();
|
||||
const mapBtn = createButton({
|
||||
label: "지도 확인",
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Address_Map_Click() {
|
||||
await map.show(base.input.value.trim());
|
||||
},
|
||||
});
|
||||
mapBtn.hidden = true;
|
||||
|
||||
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
||||
|
||||
let manual = false;
|
||||
|
||||
function pick(item: AddressCandidate): void {
|
||||
base.input.value = candidateLine(item);
|
||||
zip.textContent = item.zipcode ? `우편번호 ${item.zipcode}` : "";
|
||||
zip.hidden = !item.zipcode;
|
||||
list.hidden = true;
|
||||
list.innerHTML = "";
|
||||
map.showPoint(item.lat, item.lon);
|
||||
detail.input.focus();
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const text = query.input.value.trim();
|
||||
if (text.length < 2) {
|
||||
showToast("주소를 두 글자 이상 넣으십시오.", "error");
|
||||
return;
|
||||
}
|
||||
findBtn.disabled = true;
|
||||
setButtonLabel(findBtn, "찾는 중…");
|
||||
try {
|
||||
const items = await searchAddress(text);
|
||||
list.innerHTML = "";
|
||||
list.hidden = false;
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-dashboard__modal-text";
|
||||
empty.textContent = "찾은 주소가 없습니다. 다른 낱말로 넣거나 「직접 입력」을 쓰십시오.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.className = "b01-dashboard__address-item";
|
||||
const line = document.createElement("strong");
|
||||
line.textContent = candidateLine(item);
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = candidateHint(item);
|
||||
option.append(line, hint);
|
||||
option.addEventListener("click", () => pick(item));
|
||||
list.append(option);
|
||||
}
|
||||
} catch {
|
||||
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하십시오.", "error");
|
||||
} finally {
|
||||
findBtn.disabled = false;
|
||||
setButtonLabel(findBtn, "주소 찾기");
|
||||
}
|
||||
}
|
||||
|
||||
// 검색칸에서 Enter — 모달이 닫히지 않게 막고 찾기만 한다.
|
||||
query.input.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
void runSearch();
|
||||
});
|
||||
|
||||
// 이미 있는 주소는 열자마자 지도로 보여 준다.
|
||||
if (initial && initial.trim()) void map.show(initial.trim());
|
||||
|
||||
return {
|
||||
root,
|
||||
value: () => {
|
||||
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
||||
return merged || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -157,6 +157,16 @@ export function attachModalDismiss(
|
||||
if (ok) close();
|
||||
};
|
||||
|
||||
// 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다
|
||||
// (2026-09-13 사용자 지시 — 모달을 열어 둔 채 대시보드가 함께 굴렀다).
|
||||
modal.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.target === modal) event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
|
||||
let downOnOverlay = false;
|
||||
modal.addEventListener("mousedown", (event) => {
|
||||
|
||||
@@ -44,6 +44,8 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
createTag(`${state.company.name} (${state.user.status})`, "success"),
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
|
||||
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
|
||||
);
|
||||
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
|
||||
if (state.user.role !== "USER" && state.company) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
/**
|
||||
@@ -6,13 +7,23 @@ import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
|
||||
*
|
||||
* 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다.
|
||||
* 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다.
|
||||
* 건물을 알아볼 수 있어야 하므로 기본 배율을 17 로 두고 +·- 로 두 단계씩 움직인다
|
||||
* (2026-09-13 사용자 지시 — 15 는 너무 멀었다).
|
||||
*/
|
||||
const ZOOM = 15;
|
||||
const DEFAULT_ZOOM = 17;
|
||||
const MIN_ZOOM = 13;
|
||||
const MAX_ZOOM = 18;
|
||||
const TILE = 256;
|
||||
const GRID = 3;
|
||||
/** 타일 판은 CSS 에서 절반으로 줄여 붙인다 — 표식 자리도 같은 비율로 잡는다. */
|
||||
const SCALE = 0.5;
|
||||
|
||||
function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } {
|
||||
const n = 2 ** ZOOM;
|
||||
function tileIndex(
|
||||
lat: number,
|
||||
lon: number,
|
||||
zoom: 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;
|
||||
@@ -22,35 +33,59 @@ function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number
|
||||
export function buildAddressMap(): {
|
||||
root: HTMLElement;
|
||||
show: (address: string) => Promise<void>;
|
||||
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
||||
showPoint: (lat: number, lon: number) => 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 zoomOut = createButton({
|
||||
label: "-",
|
||||
variant: "ghost",
|
||||
onClick: () => step(-1),
|
||||
});
|
||||
const zoomIn = createButton({
|
||||
label: "+",
|
||||
variant: "ghost",
|
||||
onClick: () => step(1),
|
||||
});
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b01-dashboard__map-zoom";
|
||||
controls.append(zoomOut, zoomIn);
|
||||
controls.hidden = true;
|
||||
|
||||
const stage = document.createElement("div");
|
||||
root.append(note, controls, stage);
|
||||
|
||||
let zoom = DEFAULT_ZOOM;
|
||||
let last: { lat: number; lon: number } | null = null;
|
||||
|
||||
function step(delta: number): void {
|
||||
if (!last) return;
|
||||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom + delta));
|
||||
if (next === zoom) return;
|
||||
zoom = next;
|
||||
draw(last.lat, last.lon);
|
||||
}
|
||||
|
||||
function draw(lat: number, lon: number): void {
|
||||
last = { lat, lon };
|
||||
note.hidden = true;
|
||||
controls.hidden = false;
|
||||
zoomOut.disabled = zoom <= MIN_ZOOM;
|
||||
zoomIn.disabled = zoom >= MAX_ZOOM;
|
||||
stage.innerHTML = "";
|
||||
const center = tileIndex(lat, lon, zoom);
|
||||
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.src = `${API_BASE_URL}/dashboard/map/tile/${zoom}/${center.x + col}/${center.y + row}`;
|
||||
img.width = TILE;
|
||||
img.height = TILE;
|
||||
img.alt = "";
|
||||
@@ -59,14 +94,34 @@ export function buildAddressMap(): {
|
||||
}
|
||||
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`;
|
||||
marker.style.left = `${(half + center.dx) * TILE * SCALE}px`;
|
||||
marker.style.top = `${(half + center.dy) * TILE * SCALE}px`;
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "b01-dashboard__map-frame";
|
||||
frame.append(grid, marker);
|
||||
root.append(frame);
|
||||
stage.append(frame);
|
||||
}
|
||||
|
||||
function fail(message: string): void {
|
||||
last = null;
|
||||
stage.innerHTML = "";
|
||||
controls.hidden = true;
|
||||
note.hidden = false;
|
||||
note.textContent = message;
|
||||
}
|
||||
|
||||
const show = async (address: string): Promise<void> => {
|
||||
if (!address.trim()) {
|
||||
fail("주소를 먼저 입력하십시오.");
|
||||
return;
|
||||
}
|
||||
const point = await geocodeAddress(address).catch(() => null);
|
||||
if (!point) {
|
||||
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
||||
return;
|
||||
}
|
||||
draw(point.lat, point.lon);
|
||||
};
|
||||
|
||||
return { root, show };
|
||||
return { root, show, showPoint: draw };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
||||
import { buildAddressField } from "./B01_Dashboard_UI_AddressField";
|
||||
import {
|
||||
attachModalDismiss,
|
||||
buildUserFields,
|
||||
@@ -438,28 +438,15 @@ function companyFields(company?: CompanyInfo): {
|
||||
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);
|
||||
// 주소는 검색해서 고른다 — 손으로 정확히 치지 않아도 되게 (2026-09-13 사용자 지시).
|
||||
const address = buildAddressField(company?.business_address ?? null);
|
||||
|
||||
return {
|
||||
rows: [number.root, name.root, owner.root, logo.root, addressRow],
|
||||
rows: [number.root, name.root, owner.root, logo.root, address.root],
|
||||
values: () => ({
|
||||
name: name.input.value.trim(),
|
||||
business_registration_number: number.input.value.trim(),
|
||||
business_address: address.input.value.trim() || null,
|
||||
business_address: address.value(),
|
||||
business_owner: owner.input.value.trim() || null,
|
||||
}),
|
||||
logoFile: () => logo.input.files?.[0],
|
||||
|
||||
@@ -186,6 +186,8 @@
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2 * var(--spacing-24));
|
||||
overflow-y: auto;
|
||||
/* 패널 끝까지 굴려도 뒤 대시보드로 넘어가지 않게 가둔다 (2026-09-13 사용자 지시). */
|
||||
overscroll-behavior: contain;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -291,6 +293,99 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user