From 9ed36efe0874a19db22943d47f1dad00bf357b2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 18:10:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(B01):=20=ED=9A=8C=EC=82=AC=20=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EC=A0=9C=EB=AA=A9=20=EB=B0=94=EB=A1=9C=20=EC=98=86?= =?UTF-8?q?=20=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=9B=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD(29-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알약을 컨테이너 제목 바로 옆 왼쪽 맞춤(다섯 컨테이너 같게 · 제목과 16px) - 드롭다운 메뉴 대신 회사 고르기 창: 찾기 칸(이름 · 사업자번호 · 띄어쓰기 단어 모두) → 거른 목록(최근 등록 순 · 50줄까지 · 넘으면 좁히라는 안내) → 줄 누름 또는 Enter 로 고름 · Esc · 바깥 누름 닫기 - 공용 item_picker 는 구조물 항목 전용이라 안 씀 · B01 모달 꼴 새 파일 B01_Dashboard_UI_CompanyPick - 시험 test_b01_settings_cards 맞춤 · 거르기 시험 보탬 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- B01_Dashboard/B01_Dashboard_UI_CompanyPick.ts | 100 ++++++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 84 +++++++-------- B01_Dashboard/B01_Dashboard_UI_Style.css | 56 +++++----- resources/tester/test_b01_settings_cards.py | 43 ++++++-- ui_template/ui_template_locale_b3.ts | 6 ++ 5 files changed, 210 insertions(+), 79 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_UI_CompanyPick.ts diff --git a/B01_Dashboard/B01_Dashboard_UI_CompanyPick.ts b/B01_Dashboard/B01_Dashboard_UI_CompanyPick.ts new file mode 100644 index 00000000..8890e50f --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_CompanyPick.ts @@ -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(); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 3d0c9971..0c677c35 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index f2c2a0a3..4503d16d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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 { diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index f51ccef1..97a09c3a 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -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 diff --git a/ui_template/ui_template_locale_b3.ts b/ui_template/ui_template_locale_b3.ts index 48ad7110..6f9404c7 100644 --- a/ui_template/ui_template_locale_b3.ts +++ b/ui_template/ui_template_locale_b3.ts @@ -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: [