다크모드
FE 메인테이너 — 관리비 콘솔 탭 IA (레일 단일화·클러스터)
독자: FE 메인테이너/AI. "콘솔 파이프라인 탭 레일을 이어 개발하려면 무엇을 알아야 하나"를 정의한다. 구현 일시: 2026-06-29. 관련 spec:
docs/superpowers/specs/2026-06-29-service-charge-tab-ia-grouping-design.md. 2026-07-09 갱신(펀치 C-4): forwarding(이월) 탭에feature: 'forwarding'게이트 추가 — provisional에서 빈 화면 유발하던 결함 수정. §2·§6 참조. plan:docs/superpowers/plans/2026-07-09-forwarding-provisional-gate.md. 2026-07-22 갱신: actual 전용 조정 planned 탭을부과 → 조정 → 청구위치에 추가. route 없는 기능을 숨기거나 빈 화면으로 열지 않고 disabled·저채도·미구현배지로 표시한다. 2026-07-23 갱신: 패딩이 있는 PageHeader 단계 탐색을 EDS 기본planevariant로 정리했다.line은 full-bleed 표면 경계 전용이며, 클러스터·feature·planned 데이터 계약은 유지한다. 2026-07-26 갱신: 클러스터마다 full-widthTabsRoot를 만들던 구조를 단일TabsRoot/TabsList로 교정했다. 클러스터는 separator로만 표현하고, 모바일 배지·separator는 숨긴다. 활성 탭은 Reka의 렌더 타이밍에 의존하지 않고 제품data-tab-group으로 찾아 레일 왼쪽에 자동 정렬한다. 2026-07-28부터 exact 두께 class를 붙이지 않은 전역 기본 Native로 OS 표시 수명을 유지한다. 2026-07-26 EDS 1.2.3 정정: EDS 1.2.2는TabsList max-width:100%만 두고 trigger overflow를 List 밖으로 흘려 plane 배경이 오른쪽 구간에서 끝났다. EDS 1.2.3은 짧은 List의 콘텐츠 너비를 유지하면서 긴 List를 부모 폭에서 cap하고, List 자체가 plane 배경과 scroll viewport를 함께 소유한다. 제품의 임시w-full min-w-0 overflow-x-auto는 제거하고 표시 정책만 남긴다. 2026-07-27 재확정: 모바일 Select와 2단 업무 범위 탐색 실험은 폐기했다. 회차의 전체 업무 순서와 인접 단계를 한눈에 유지하기 위해 전 viewport에서 단일 가로 탭 레일을 사용하고, 클러스터는 데스크톱 구분선으로만 표현한다.
1. 아키텍처 — 단일 소스 원칙
관리비 콘솔 파이프라인 탭 레일은 ConsoleTabBarOrg 컴포넌트 하나가 담당한다. 탭 목록·라벨·순서는 모두 billingLines.js의 service-charge consoleTabs 배열에서 파생된다. 탭 변경은 이 1개 파일만 수정하면 전 페이지에 반영된다.
billingLines.js → BILLING_LINES['service-charge'].consoleTabs
↓ (useBillingEdition().consoleTabs — computed ref)
ConsoleTabBarOrg.vue
↓ (:active-group="…" prop)
6개 HeaderOrg + receivable-aging MainOrg2. consoleTabs 구조
src/composables/billingLines.js service-charge consoleTabs 배열의 각 항목:
| 필드 | 타입 | 설명 |
|---|---|---|
group | string | 탭 식별자 — active-group prop과 대조해 active 결정 |
labelKey | string | $t() 번역 키 |
seg | string | billingPath(seg)로 변환되는 URL 세그먼트 |
cluster | string | 'overview' / 'process' / 'receivable' / 'tax' / 'report' — 단일 tabs-list 안에서 인접 업무군 사이 separator를 결정 |
feature | string (선택) | hasFeature(feature)가 false면 탭을 숨김 |
editions | string[] (선택) | 지정된 에디션에서만 위치를 노출 |
availability | string (선택) | 'planned'이면 위치만 보존하고 disabled·미구현으로 렌더 |
badgeI18n | string (선택) | 배지 텍스트 번역 키 |
badgeKey | string (선택) | billing.value[badgeKey]에서 배지 텍스트를 동적으로 읽음 |
badgeClass | string (선택) | 배지 EDS 클래스 |
actual 기준 현재 11개 탭 (클러스터 순서):
| 순서 | group | cluster |
|---|---|---|
| 1 | general | overview |
| 2 | charging | process |
| 3 | adjustment | process (editions: actual, availability: planned) |
| 4 | invoicing | process |
| 5 | collecting | process |
| 6 | notice | process (feature: notice) |
| 7 | forwarding | receivable (feature: forwarding) |
| 8 | late-fee | receivable |
| 9 | advance-receipt | receivable (feature: advanceReceipt) |
| 10 | tax-invoice | tax |
| 11 | report | report (feature: reports) |
forwarding feature 게이트(2026-07-09, 펀치 C-4): BILLING_LINES['service-charge'].editions.actual.features에만 'forwarding'이 있고 provisional.features는 []이므로, forwarding 탭은 actual 에디션에서만 노출된다. 이유: buildLineRoutes.js의 provisional 매니페스트(own)와 공유 core 목록(SERVICE_CHARGE_CORE) 어디에도 forwarding/brought-forward 라우트가 없다(actual 전용 own 라우트) — 탭이 라우트 없는 provisional에서도 노출되면 클릭 시 빈 화면이 뜬다(수정 전 재현 100%). notice·advance-receipt와 동일한 패턴으로 게이트해 라우트 존재 여부와 탭 노출을 일치시켰다. provisional에 이월 개념이 없는 것은 의도(잠정정산 스냅샷은 회계기간 roll-forward 대상이 아님).
3. ConsoleTabBarOrg 컴포넌트
파일: src/components/billing/_core/console/ConsoleTabBarOrg.vue
3-1. Props
| prop | 타입 | 필수 | 설명 |
|---|---|---|---|
activeGroup | String | 필수 | 현재 활성 탭의 group 값. 해당 탭이 data-state="active" |
3-2. 내부 동작
js
const { billing, billingPath, hasFeature, consoleTabs } = useBillingEdition();
// 에디션·feature 범위 밖만 제외. planned는 위치를 보존한다.
const visibleTabs = computed(() =>
consoleTabs.value.filter(
(tab) =>
(!tab.editions || tab.editions.includes(billing.value.edition)) &&
(!tab.feature || hasFeature(tab.feature)),
),
);- 전체
visibleTabs를 한 개의TabsRoot/TabsList에 렌더한다. 서로 다른cluster가 시작되는 지점에 장식용separator-vertical만 삽입한다. - 모바일(
sm미만)은 상태 배지와 업무군 separator를 숨겨 탭 레일을 줄인다. 상태 텍스트는 trigger의aria-label·title에 보존한다. separator는 데스크톱에서만 보조 시각 구분으로 표시한다. - 각 trigger에
data-tab-group="{group}"을 둔다. mount·activation과activeGroup변경 뒤 두 paint를 기다린 후 해당 trigger의offsetLeft - 16px로TabsListviewport를 이동한다. Reka의data-state가 붙는 시점보다 먼저 조회해 이전 스크롤 위치가 남는 회귀와,scrollIntoView()가 외부 스크롤 조상까지 움직이는 부작용을 피한다. TabsRoot.page-header-below는 헤더의 고정 배경·패딩·48px 높이와 가용 폭을 소유한다. EDS 1.2.3의 내부TabsList가 intrinsic width cap·plane 배경·가로 overflow를 함께 소유하므로 스크롤 끝까지 배경이 연속된다. 제품은 별도 scrollbar class 없이 전역 기본 Native를 사용한다. 모바일에서는 OS가 swipe 직후 indicator를 표시했다가 숨기며, 상시 custom rail이나 별도 gutter를 만들지 않는다.availability==='planned'탭은disabled,data-status='unimplemented', 번역된 미구현 배지·title을 제공한다.go(tab): planned면 즉시 반환한다. 그 외 현재 group과 다른 탭만router.push(billingPath(tab.seg))한다.
3-3. 렌더 구조
html
<Tabs class="page-header-below sm:px-6 bg-neutral-minimal" :model-value="activeGroup">
<TabsList variant="plane" class="tabs-list-primary-moderate tabs-list-radius-full">
<!-- cluster 경계 separator + TabsTrigger 전체 -->
</TabsList>
</Tabs>page-header-below:has(> .tabs-list)의 48px local-navigation 계약은 TabsRoot가 container class를 직접 소유하고 TabsList를 직접 자식으로 둘 때 성립한다. page-header-below-lg는 block padding 8px 규칙의 specificity가 더 높아 이 계약을 58px로 되돌리므로 탭 레일에는 합성하지 않는다. 데스크톱 inline 24px은 sm:px-6로만 보존한다. 외부 <div> 아래 여러 TabsRoot(width:100%)를 두면 actual 모바일 379px 뷰포트에서 레일이 1,799px까지 팽창하고 특수 selector도 불일치하므로 금지한다. 스크롤은 EDS 1.2.3의 직접 자식 TabsList가 맡고, 제품은 List에 폭·overflow 보정 utility를 추가하지 않는다.
planned 탭도 EDS tabs-trigger와 badge-neutral-subtle만 사용한다. 로컬 opacity·cursor·색상 override는 추가하지 않는다.
4. 소비처 — active-group 매핑
ConsoleTabBarOrg를 렌더하는 7개 파일과 각자의 active-group 값:
| 파일 | active-group |
|---|---|
service-charge/actual/console/charging/assessment/HeaderOrg.vue | 'charging' |
service-charge/actual/console/charging/allocation-unit/HeaderOrg.vue | 'charging' |
service-charge/actual/console/charging/allocation-contract/HeaderOrg.vue | 'charging' |
service-charge/actual/console/charging/allocation-member/HeaderOrg.vue | 'charging' |
service-charge/actual/console/notice/general/HeaderOrg.vue | 'notice' |
service-charge/actual/console/notice/individual/HeaderOrg.vue | 'notice' |
billing/_core/console/receivable-aging/MainOrg.vue | 'forwarding' |
나머지 콘솔 페이지(invoicing, collecting, general, forwarding/late-fee/advance-receipt, tax-invoice)는 이미 ConsoleTabBarOrg를 사용하고 있었으며, 이번 작업으로 전 페이지 단일 레일 상태가 됐다.
5. 스테이지 서브탭 — 별개 컴포넌트
blocks/TabBarOrg(파이프라인 스테이지 내부 서브탭)는 이 레일과 별개다. 이 하위 보기 탭은 plane variant(default)를 사용하며 TabsList 자체가 유일한 배경 표면을 소유한다.
| 서브탭 | 위치 |
|---|---|
| 부과 assessment↔allocation 전환 | charging/*/blocks/TabBarOrg.vue |
| 공지 general↔individual 전환 | notice/*/blocks/TabBarOrg.vue |
| 미수금 연령분석 axis(계약/유닛/멤버) | receivable-aging/ 내 TabBarOrg |
2026-07-27 actual 부과 하위의 부과산정·유닛명세·계약명세·멤버명세는 charging/ChargingViewTabBarOrg.vue 하나를 공유한다. 공용 탭은 기존 청구의 계약·유닛·멤버 축 탭과 동일한 page-header-below p-1 bg-neutral-minimal rounded-full 독립 내비게이션 표면에 배치하고, 알림·도구·표가 있는 작업 카드와 분리한다. 이 구조는 작업 카드 header를 제목·상태·기능용으로 보존하면서 회색 탭 plane을 4px inset의 흰 pill로 감싼다.
6. 범위 밖 (이번 작업에서 미접촉)
- 설정 nav 레일 —
LineAsideOrg/ sidebar 구성과 무관. - provisional 에디션은 전용 탭 분기 미설계(현 consoleTabs 공유). 클러스터·구분선은
visibleTabs필터로 정상 동작 — notice·advance-receipt·forwarding(2026-07-09부터) feature-gate 숨김 후에도 경계 구분선 표시. - 라우터 catch-all/404 미구현(follow-up, 이번 스코프 밖) — provisional 라우트가 없는 세그먼트로 직접 URL 진입(북마크·수동 입력)하면 여전히 빈 화면. 탭 클릭 경로는 이번 수정으로 닫혔으나, 직접 URL 방어는 전 라인 공통 라우터 차원 결정이 필요해 별도 과제로 남긴다.
- 조정 업무 구현 — 이번 변경은 actual 콘솔의 예정 위치와 미구현 상태만 표시한다. route·화면·서버 조정 Fact·stage 전이는 범위 밖이다.
- lease / hospitality / sales / purchasing 라인 —
consoleTabs에cluster필드 미부여. 이때 각group을 fallback cluster로 취급해 업무 사이 separator가 표시된다. 필요하면 라인별 업무군을cluster로 명시한다.
7. ⚠ 메인테이너 주의 — consoleTabs ref 언래핑
useBillingEdition()에서 반환하는 consoleTabs는 computed ref다. <script setup> 블록에서 직접 다룰 때는 반드시 .value를 통해 접근해야 한다.
js
// ✅ 올바른 사용
const visibleTabs = computed(() => consoleTabs.value.filter(...))
// ❌ 잘못된 사용 — ref에 직접 .filter() 호출 → 런타임 오류
const visibleTabs = computed(() => consoleTabs.filter(...))build·vitest 모두 이 오류를 잡지 못한다 — 컴포넌트를 마운트하는 테스트가 없기 때문이다. 브라우저/Playwright에서만 발견된다. 탭 레일 변경 후 Playwright 실측이 필수 검증 게이트다.
8. 테스트
| 대상 | 파일 |
|---|---|
| consoleTabs 클러스터 불변식 — 모든 탭 cluster 보유, 클러스터 연속, 순서(overview→process→receivable→tax→report), process의 조정 위치 | src/composables/__tests__/billingLinesClusters.spec.js |
| forwarding 탭 에디션 게이트와 actual 전용 adjustment planned 메타 | src/composables/__tests__/billingLines.spec.js |
실제 탭 렌더 — 단일 root/list·EDS-owned intrinsic cap·plane/scroll·제품 레이아웃 보정 0·desktop-only cluster separator·stable data-tab-group·native 2px, actual 조정 disabled·미구현·이동 없음, provisional 비노출 | src/components/billing/_core/console/__tests__/ConsoleTabBarOrg.spec.js |
실행: npx vitest run src/composables/__tests__/billingLinesClusters.spec.js src/composables/__tests__/billingLines.spec.js src/components/billing/_core/console/__tests__/ConsoleTabBarOrg.spec.js
브라우저 시나리오(수동): 360px·412px·448px에서 /service-charge/actual/console/general과 /service-charge/actual/console/collecting/contract에 각각 직접 진입한다. 탭 레일이 viewport 배수로 팽창하지 않는지, 현재 탭이 16px 안쪽의 레일 시작점에 보이는지, 모바일 배지·separator가 숨는지, native indicator가 swipe 후 사라지는지 확인한다. 대표 수치 검증은 412px에서 한다. 데스크톱에서는 배지와 cluster separator가 보이고 키보드 화살표가 한 tablist 전체를 이동해야 한다.