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:
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user