fix(B01): 회사 알약 제목 바로 옆 · 누르면 조회 후 고르기 창(29-5)

- 알약을 컨테이너 제목 바로 옆 왼쪽 맞춤(다섯 컨테이너 같게 · 제목과 16px)
- 드롭다운 메뉴 대신 회사 고르기 창: 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두) → 거른 목록(최근 등록 순 · 50줄까지 · 넘으면 좁히라는 안내) → 줄 누름 또는 Enter 로 고름 · Esc · 바깥 누름 닫기
- 공용 item_picker 는 구조물 항목 전용이라 안 씀 · B01 모달 꼴 새 파일 B01_Dashboard_UI_CompanyPick
- 시험 test_b01_settings_cards 맞춤 · 거르기 시험 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z
This commit is contained in:
2026-09-30 18:10:21 +09:00
co-authored by Claude Opus 5.5
parent 95849aefbd
commit 9ed36efe08
5 changed files with 210 additions and 79 deletions
@@ -0,0 +1,100 @@
/* =============================================================================
* 회사 조회 후 고르기(시스템 관리자 회사 알약 · 29-5)
* 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두 맞음) → 거른 목록(처음엔 최근 등록 순) →
* 줄 누르면 고름. 목록은 LIMIT 줄까지만 그림 — 회사가 많아도 찾기로 좁힘.
* ========================================================================== */
import { createButton, createInputField } from "@ui/ui_template_elements";
import type { CompanyInfo } from "./B01_Dashboard_Api_Fetch";
import { L } from "./B01_Dashboard_UI_Common";
const LIMIT = 50;
/** 이름 · 사업자번호(줄표 빼고도)에 모든 단어가 들어간 회사 — 순서는 받은 그대로 */
export function filterCompanies(companies: CompanyInfo[], text: string): CompanyInfo[] {
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
return companies.filter((c) => {
const no = c.business_registration_number ?? "";
const hay = `${c.name} ${no} ${no.replace(/-/g, "")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
export function openCompanyPicker(
companies: CompanyInfo[],
currentId: number | null,
onPick: (company: CompanyInfo) => void,
): void {
const modal = document.createElement("div");
modal.className = "b01-dashboard__modal";
const panel = document.createElement("div");
panel.className = "b01-dashboard__modal-panel";
const heading = document.createElement("h3");
heading.className = "b01-dashboard__modal-title";
heading.textContent = L("B01_Dashboard_PickCompany");
const query = createInputField({
label: L("B01_Dashboard_Field_Search"),
placeholder: L("B01_Dashboard_PickCompanyHint"),
});
const count = document.createElement("p");
count.className = "b01-dashboard__company-count";
const list = document.createElement("div");
list.className = "b01-dashboard__company-list";
const close = (): void => {
modal.remove();
document.removeEventListener("keydown", onKey, true);
};
const pick = (company: CompanyInfo): void => {
close();
onPick(company);
};
let shown: CompanyInfo[] = [];
const render = (): void => {
const found = filterCompanies(companies, query.input.value);
shown = found.slice(0, LIMIT);
count.textContent =
found.length > LIMIT
? L("B01_Dashboard_PickCompanyMore")
.replace("{count}", String(found.length))
.replace("{limit}", String(LIMIT))
: L("B01_Dashboard_PickCompanyCount").replace("{count}", String(found.length));
list.replaceChildren(
...shown.map((company) => {
const row = document.createElement("button");
row.type = "button";
row.className = "b01-dashboard__company-row";
row.classList.toggle("is-current", company.id === currentId);
const name = document.createElement("span");
name.textContent = company.name;
const no = document.createElement("small");
no.textContent = company.business_registration_number ?? "";
row.append(name, no);
row.addEventListener("click", () => pick(company));
return row;
}),
);
};
// Enter = 첫 줄 고름 · Esc = 닫기
const onKey = (event: KeyboardEvent): void => {
if (event.key === "Escape") close();
else if (event.key === "Enter" && event.target === query.input && shown[0]) pick(shown[0]);
else return;
event.preventDefault();
event.stopPropagation();
};
query.input.addEventListener("input", render);
document.addEventListener("keydown", onKey, true);
modal.addEventListener("click", (event) => {
if (event.target === modal) close();
});
const actions = document.createElement("div");
actions.className = "b01-dashboard__actions";
actions.append(createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }));
panel.append(heading, query.root, count, list, actions);
modal.append(panel);
document.body.append(modal);
render();
query.input.focus();
}
+39 -45
View File
@@ -7,7 +7,6 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createMapContextMenu, type MapContextMenu } from "@ui/ui_template_context_menu";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router";
@@ -40,6 +39,7 @@ import {
} from "./B01_Dashboard_UI_Company";
import { openAddMemberModal, openCreateCompanyModal } from "./B01_Dashboard_UI_Modals";
import { buildProfileForm, buildSecurityForm } from "./B01_Dashboard_UI_Profile";
import { openCompanyPicker } from "./B01_Dashboard_UI_CompanyPick";
import { projectTable } from "./B01_Dashboard_UI_Projects";
import { buildResourcePanel } from "./B01_Dashboard_UI_Resources";
import { buildTempUploadSection } from "./B01_Dashboard_UI_TempUpload";
@@ -200,11 +200,8 @@ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null)
return wrap;
}
/** 회사 메뉴 — 공용 작은 메뉴 하나를 알약 모두가 같이 씀 */
let companyMenu: MapContextMenu | null = null;
/** 회사 알약(시스템 관리자만) — 누르면 등록된 회사 메뉴 · 고르면 저장 뒤 대시보드를 다시 읽음 */
function companyPill(state: DashboardState): HTMLElement {
/** 회사 알약(시스템 관리자만) — 컨테이너 제목 바로 옆 · 누르면 조회 후 고르기 · 고르면 저장 뒤 다시 읽음 */
function withCompanyPill(card: HTMLElement, state: DashboardState): HTMLElement {
const name = state.allCompanies.find((c) => c.id === state.adminCompanyId)?.name ?? "-";
const pill = document.createElement("button");
pill.type = "button";
@@ -214,32 +211,21 @@ function companyPill(state: DashboardState): HTMLElement {
pill.addEventListener("click", (event) => {
// 제목 줄 누름 = 접힘 — 알약 누름은 올려 보내지 않음
event.stopPropagation();
if (!companyMenu?.element.isConnected) {
companyMenu = createMapContextMenu("b01-dashboard");
document.body.append(companyMenu.element);
}
const box = pill.getBoundingClientRect();
companyMenu.open(
box.left,
box.bottom + 4,
state.allCompanies.map((c): [string, () => void] => [
c.name,
() => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
},
]),
);
openCompanyPicker(state.allCompanies, state.adminCompanyId, (company) => {
sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id));
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
});
return pill;
card.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill);
return card;
}
/** 시스템 설정 컨테이너 — 모든 역할 · 한 줄 통째 · 제목 눌러 통째로 접힘 */
function buildSystemSettingsSection(state: DashboardState): HTMLElement {
const { user } = state;
const pill = user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : [];
const panel = buildSystemSettingsPanel(user, state.adminCompanyId);
const card = section(L("B01_Dashboard_SystemSettings"), panel, true, pill);
const card = section(L("B01_Dashboard_SystemSettings"), panel, true);
if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);
card.querySelector(".ui-card__body")?.classList.add("ui-collapsible");
card.querySelector(".b01-dashboard__section-header")?.classList.add("ui-collapsible__title");
attachCollapsible(card);
@@ -259,28 +245,36 @@ function buildPage(state: DashboardState): HTMLElement {
grid.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
buildSystemSettingsSection(state),
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
companyPill(state),
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
]),
withCompanyPill(
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects, state.user), true, [
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
]),
state,
),
buildTempUploadSection(),
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
companyPill(state),
createButton({
label: "+",
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
}),
]),
section(
L("B01_Dashboard_JoinRequests"),
joinRequestTable(state.allJoinRequests, true),
true,
[companyPill(state)],
withCompanyPill(
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
createButton({
label: "+",
onClick: () => openAddMemberModal(undefined, state.adminCompanyId),
}),
]),
state,
),
withCompanyPill(
section(
L("B01_Dashboard_JoinRequests"),
joinRequestTable(state.allJoinRequests, true),
true,
),
state,
),
withCompanyPill(
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
]),
state,
),
section(L("B01_Dashboard_Companies"), companyTable(picked, state.user), true, [
companyPill(state),
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
]),
);
} else if (state.user.role === "ADMIN") {
// 시스템 설정은 맨 위 한 줄(27-3a)
+29 -27
View File
@@ -68,37 +68,38 @@
font-size: var(--text-caption);
}
/* 회사 알약 — 회사를 따라 바뀌는 컨테이너 제목 오른쪽 · 누르면 회사 메뉴 */
/* 회사 알약 — 회사를 따라 바뀌는 컨테이너 제목 바로 옆(왼쪽 맞춤) · 누르면 조회 후 고르기 */
.b01-dashboard__company-pill {
margin-right: auto;
border: 0;
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.b01-dashboard__context-menu {
position: fixed;
z-index: var(--z-modal);
.b01-dashboard__company-count {
margin: var(--spacing-8) 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b01-dashboard__company-list {
display: flex;
flex-direction: column;
min-width: 160px;
max-height: 60vh;
max-height: 320px;
overflow-y: auto;
padding: var(--spacing-4);
overscroll-behavior: contain;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.b01-dashboard__context-menu[hidden] {
display: none;
}
.b01-dashboard__context-menu-item {
padding: var(--spacing-8) var(--spacing-12);
.b01-dashboard__company-row {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-16);
border: 0;
border-radius: var(--radius-cards);
border-bottom: 1px solid var(--color-border);
background: transparent;
color: inherit;
font: inherit;
@@ -107,18 +108,19 @@
cursor: pointer;
}
.b01-dashboard__context-menu-item:hover {
.b01-dashboard__company-row:last-child {
border-bottom: 0;
}
.b01-dashboard__company-row small {
color: var(--color-text-muted);
}
.b01-dashboard__company-row:hover,
.b01-dashboard__company-row:focus-visible,
.b01-dashboard__company-row.is-current {
background: var(--color-surface-sunken);
}
/* 접히는 제목 줄 — 단추 칸을 접힘 표시 바로 왼쪽으로 */
.ui-collapsible__title > .b01-dashboard__actions {
margin-left: auto;
}
.b01-dashboard__company-pick .ui-field__label {
margin: 0;
white-space: nowrap;
outline: none;
}
.b01-dashboard__actions {
+36 -7
View File
@@ -113,7 +113,7 @@ def test_모든_역할_한_줄_통째로_접힘():
assert "StructureManage" not in src and "buildStructurePanel" not in src
assert "top-row" not in src
sect = _fn(src, "buildSystemSettingsSection")
assert 'L("B01_Dashboard_SystemSettings"), panel, true, pill)' in sect # 한 줄 통째
assert 'L("B01_Dashboard_SystemSettings"), panel, true)' in sect # 한 줄 통째
assert '"ui-collapsible"' in sect and '"ui-collapsible__title"' in sect
assert "attachCollapsible(card)" in sect
config = _text(ROOT / "config" / "config_frontend.ts")
@@ -123,12 +123,13 @@ def test_모든_역할_한_줄_통째로_접힘():
def test_회사_알약_시스템_관리자만():
src = _text(PAGE)
sect = _fn(src, "buildSystemSettingsSection")
assert 'user.role === "SYSTEM_ADMIN" ? [companyPill(state)] : []' in sect
pill = _fn(src, "companyPill")
assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(c.id))" in pill
assert 'if (user.role === "SYSTEM_ADMIN") withCompanyPill(card, state);' in sect
pill = _fn(src, "withCompanyPill")
assert "sessionStorage.setItem(ADMIN_COMPANY_KEY, String(company.id))" in pill
assert "event.stopPropagation()" in pill # 알약 누름이 접힘으로 새지 않음
assert 'createMapContextMenu("b01-dashboard")' in pill # 공용 작은 메뉴
assert "openCompanyPicker(state.allCompanies" in pill # 조회 후 고르기(29-5)
assert '"ui-tag ui-tag--accent b01-dashboard__company-pill"' in pill # 공용 알약 모양
assert '.querySelector(".b01-dashboard__section-header > .ui-card__title")?.after(pill)' in pill
assert 'const ADMIN_COMPANY_KEY = "aislo.adminCompany";' in src # M02 와 같은 키
load = src.split("async function loadRoleData")[1].split("\n}\n")[0]
for call in ("fetchAllUsers(companyId)", "fetchCompanyJoinRequests(companyId)",
@@ -137,8 +138,8 @@ def test_회사_알약_시스템_관리자만():
build = _fn(src, "buildPage")
# 회사 따라 바뀌는 컨테이너 넷(프로젝트 · 사용자 관리 · 가입 요청 · 회사 관리)마다 알약
sys_part = build.split('} else if (state.user.role === "ADMIN")')[0]
assert sys_part.count("companyPill(state)") == 4
assert "companyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1]
assert sys_part.count("withCompanyPill(") == 4
assert "withCompanyPill" not in build.split('} else if (state.user.role === "ADMIN")')[1]
assert "companyTable(picked, state.user)" in build
assert "openAddMemberModal(undefined, state.adminCompanyId)" in build
@@ -187,3 +188,31 @@ def test_서버_회사_인자_한_곳():
assert "require_system_admin" in users and "list_all_users(company_id)" in users
assert "router.include_router(assets_router)" in router
assert len(router.splitlines()) <= 700
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_회사_찾기_거르기():
"""이름 · 사업자번호(줄표 빼고도) · 띄어쓰기 단어 모두 맞음(29-5)."""
src = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_CompanyPick.ts")
found = re.search(r"^export function filterCompanies[\s\S]*?^}", src, re.M)
assert found, "filterCompanies 를 못 찾음"
script = (
"interface CompanyInfo { id: number; name: string;"
" business_registration_number?: string | null }\n"
+ found.group(0).replace("export ", "")
+ "\nconst cs = [{ id: 1, name: '캐미팩토리',"
+ " business_registration_number: '123-45-67890' },"
+ " { id: 3, name: '브레인검증회사', business_registration_number: null },"
+ " { id: 4, name: '캐미 건설', business_registration_number: '999-00-11111' }];"
+ "\nconst ids = (t: string) => filterCompanies(cs, t).map((c) => c.id);"
+ "\nconsole.log(JSON.stringify([ids(''), ids('캐미'), ids('캐미 건설'), ids('1234567890'),"
+ " ids('123-45'), ids('브레인 없음'), ids(' ')]));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [[1, 3, 4], [1, 4], [4], [1], [1], [], [1, 3, 4]]
assert "const LIMIT = 50;" in src and "found.slice(0, LIMIT)" in src
+6
View File
@@ -146,6 +146,12 @@ export const ui_locales_b3 = {
B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"],
B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"],
B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"],
B01_Dashboard_PickCompanyHint: ["회사 이름 · 사업자번호", "Company name · business number"],
B01_Dashboard_PickCompanyCount: ["{count}곳", "{count} companies"],
B01_Dashboard_PickCompanyMore: [
"{count}곳 중 {limit}곳만 보임 — 찾기로 좁힐 것",
"Showing {limit} of {count} — narrow the search",
],
/* --- B08·B09 빈 화면 (PLAN 7-3) --- */
B08_Quantity_Rebuilding: [