From 62dc2d744c39502d85c1dfa71878bf1edb89370c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 13 Sep 2026 11:32:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(b01):=20=ED=9A=8C=EC=82=AC=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EA=B2=80=EC=83=89=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EB=8A=94=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 손으로 정확히 쳐야만 되던 주소칸을 후보 검색·선택 방식으로 바꿈. VWorld 주소검색을 서버가 중계하고, 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 그림. 못 찾는 주소는 「직접 입력」으로 옛 방식을 씀. - 회사 목록 SQL 이 business_address·business_owner 를 안 가져와 수정 모달이 비어 열리고 저장 시 빈 값으로 덮어쓰던 것 수정 - 지도 기본 배율 15 → 17, +·- 단계 조절 추가 - 라벨·입력·버튼을 한 줄에 눕혀 줄 어긋남 해소 - 모달 안 휠이 뒤 대시보드로 새던 것 차단 - 회사 패널에 주소 한 줄 표시 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 19 ++ B01_Dashboard/B01_Dashboard_Map.py | 57 ++++++ .../B01_Dashboard_Repository_Company.py | 1 + B01_Dashboard/B01_Dashboard_Router.py | 12 +- .../B01_Dashboard_UI_AddressField.ts | 172 ++++++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Common.ts | 10 + B01_Dashboard/B01_Dashboard_UI_Company.ts | 2 + B01_Dashboard/B01_Dashboard_UI_MapPreview.ts | 103 ++++++++--- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 23 +-- B01_Dashboard/B01_Dashboard_UI_Style.css | 95 ++++++++++ 10 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_AddressField.ts diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index e258ca87..cd122ca1 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -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 { + const body = await request<{ items?: AddressCandidate[] }>( + `/dashboard/company/address/search?query=${encodeURIComponent(query)}`, + ); + return body.items ?? []; +} + /** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */ export function updateCompany( payload: { diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py index 6fbcf33c..0c26c5f3 100644 --- a/B01_Dashboard/B01_Dashboard_Map.py +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py index c4a6ae6a..c4eddf1d 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Company.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -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 diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 0635b8dc..9854c4be 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -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), diff --git a/B01_Dashboard/B01_Dashboard_UI_AddressField.ts b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts new file mode 100644 index 00000000..7a50998e --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_AddressField.ts @@ -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 { + 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; + }, + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index b7ba551d..1674b3ca 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -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) => { diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index b6a7af21..17b08a8b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -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) { diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts index d87137b6..ed616c67 100644 --- a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -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; + /** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */ + 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 => { - 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 => { + 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 }; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 9af03c60..c54e6b4e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -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], diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 9eb0b919..35f94502 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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;