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 =>
|
||||
`${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[] }>(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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}%"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user