다크모드
FE 메인테이너 — 3축 빌링 테이블·상세시트
독자: FE 메인테이너/AI. 코어 6화면(부과·청구·수납 × 유닛/멤버/계약)을 이어 개발하려면 알아야 할 것. 정본:
docs/decisions/PATTERN-TENANCY-AXES-CONTRACT-UNIT-MEMBER-2026-06-22.md(§1~§9). BE: [[tenancy-axes-billing.md]]. 관련 FE: [[collecting-detail-sheet.md]].
1. 라우트·컴포넌트 위치
- 수납/청구 =
_core공유:src/components/billing/_core/console/{collecting,invoicing}/{unit,member,contract}/{MainOrg,blocks/DynamicTableOrg}.vue. - 부과 = 라인별 경로(아직 _core 미통합):
src/components/service-charge/actual/console/charging/allocation-{unit,member,contract}/blocks/DynamicTableOrg.vue. 라우트…/console/charging/allocation-{unit|contract|member}(buildLineRoutes.js). - MainOrg = TabBar+ToolBar+DynamicTable+Pagination+ActionBar + 오버레이 시트 마운트.
2. 데이터 소스 (composables)
useAllocations— 배분 캐논.allocate(charge),contractColumns/unitColumns/memberColumns(행렬 헤더),contractAtoms(),unitOccupants(uc)/memberHoldings(mc)(펼침),invoicingBy{Contract,Unit,Member}.useForwarding—forwardingBy{Contract,Unit,Member}(차수·미수·연체),collect.useCollectingDetail—detailFor(axis,key,asOf)= 통합 합산 상세(buildOne+mergePeriods). 수납·청구 상세 공통 소스.useBillingEdition(isCanon = service-charge ∧ actual),useModuleSubscription(accountingEnabled게이팅).
3. 리스트 테이블 패턴 (수납/청구)
- 유닛/멤버: 행 = forwarding 행(canon
buildCanonRows). 컬럼 = 유닛/멤버 + 대표상대 + 당월N + 시작/종료 + 총청구금 + 미수금(수납)/전송(청구). 펼침(expanded[id]) =unitOccupants/memberHoldings(점유자/보유유닛별 실배분, 시작/종료·금액). 펼침행 클릭 → 실contractCode로 상세. - 계약:
atomRows(canon) =contractAtoms+ forwarding 조인(총청구금/미수금/상태). flat + 유닛/멤버 헤더 정렬(sortKey/sortDir/toggleSort). non-canon은 기존 cc행+펼침(v-else). - 진입 IA(§0-10): 주 식별자 클릭=상세(오버레이),
table-hover+font-medium+hover:underline. 풀페이지 허브는 ↗(유닛만). - 수납 버튼: 완납에도 노출(추가납부=예수금),
isCollectable = status !== '대손'.
수납 canon 컬럼 (트리아지) — 추가 이력
수납 3축 canon 컬럼 = 식별 + 시작/종료 + 총청구금 · 수납금 · 미수금 · 연체료 · 상태 · (전기) · 마지막수납일 · 수납처리. 컬럼 추가 시 헤더+메인행+canon 펼침 서브행(유닛/멤버; 계약 flat=서브행 無)+양 thead 5단계 전수 + Playwright 펼침 상태까지 검증(과거 1칸 시프트 회귀 빈발).
- 마지막수납일(2026-06-28):
useReceipts().lastReceivedAtForContracts(contractCodes)= 식별·미취소 영수증 중 maxreceivedAt. 축 매핑 = contract[a.contractCode]/ unitunitOccupants(key).map(o=>o.contractCode)/ membermemberHoldings(key).map(h=>h.contractCode). 펼침 서브행은 행 단위([m.contractCode]/[u.contractCode]). 빈 값—. 위치 = 전기 뒤·수납처리 앞. as-of(과거) 행은''(범위 외). - 상태 부분수납 구분(2026-06-28): canon row status =
total===0 ? 완납 : (Σperiods.collected>0 ? 부분수납 : 미수). 부분수납=앰버 뱃지(statusBadge)로 완전미납(빨강)과 분리. ⚠ 이는 컴포넌트 row 단위 —useForwarding.periodStatus(차수 단위)와 별개(테스트 무관).
3-1. 목록 툴바 — 실배선/정직비활성 구분 (2026-07-10)
수납 3축(collecting/{contract,member,unit}/blocks/DataToolBarOrg.vue)의 목록 툴바 버튼:
- 고지서 — 캐논(
isCanon = billing.line==='service-charge' && billing.edition==='actual')에서만 실배선.command="show-modal" commandfor="invoice-preview-art"→MainOrg.vue에 마운트된<dialog id="invoice-preview-art"><InvoiceDynamicOrg /></dialog>를 연다. 비캐논은disabled title="준비 중이에요". - 명세서 · 시트 — export 백엔드 부재로 두 버튼 모두 항상
disabled title="준비 중이에요"(캐논 여부 무관). - 더보기(⋮,
more_vert) — 수납 3축·청구(invoicing) 3축ActionBarOrg.vue에서 완전 제거(원래 아무 기능도 연결돼 있지 않던 죽은 아이콘).
같은 원칙(캐논=실배선/비캐논 또는 백엔드 부재=disabled title="준비 중이에요")이 세금계산서 콘솔의 "전송 재시도" 버튼과 charging 콘솔의 항목명 드릴다운에도 적용됐다 — 각각 docs/handoff/backend/tax-documents.md §4-1, 아래 참고.
charging(부과/배분) 콘솔 항목명 드릴다운(펀치리스트 I-2, 실위치): assessment·allocation-{unit,member,contract}(actual)·allocation(provisional) 총 5화면에서 항목명 클릭이 999,999,999 정적 mock AccountDetailArt 오버레이를 열던 것을 제거 — 클릭 가능한 텍스트(<span class="font-medium">, affordance 없음) 또는 disabled 아이콘 버튼으로 전환. invoicing(청구) 3축의 동명 오버레이는 애초에 트리거가 없는 고아 마운트였음(조사 결과, 감사 원문의 위치 표기는 부정확했다) — 그쪽은 무변경.
4. 상세 시트 — 2종
- 수납 canon =
collecting/detail/blocks/SheetCollectingDetailArt.vue(idsheet-collecting-detail-art).selectedCollecting={axis,key}→detailFor. 식별부(detail.identity) +AllocationStatusOrg(차수) + 수납처리/선수금/수납내역/조정. 제목 axis별("유닛/멤버 통합 고지서"/"계약 상세"). - 청구 + non-canon =
overlays/SheetReadBillingArt.vue(idsheet-read-billing-art).selectedBilling에axis+key있으면detailFor로 실데이터(식별부·AllocationStatusOrg·총청구금·선수금·수납내역), 없으면 데모 폴백(v-if="detail"/v-else). ⚠ 청구 unit/member/contract MainOrg 모두 이 시트 마운트 필요(과거 unit/member 누락 버그). AllocationStatusOrg(collecting/detail/blocks) =:periods받아 차수별 미수·수납 렌더(재사용). 도출근거는sheet-derivation-art(있으면).
5. 부과 행렬 (charging)
- 행 =
charges(부과항목), 열 = 축 엔티티({contract,unit,member}Columns), 셀 =cellAmount(charge, group)=Σ amountsByCharge[charge.id][line.id]over group의 그 charge 라인. 합계행(columnTotal) + 부제(호수/점유자/보유유닛, 다수=외 N). - ⚠ 금지 패턴(고친 버그): 열을
flatMap(g.lines)(charge×unit 중복)로 잡고amountsByCharge[charge][cell.line.id]직접 → off-diagonal NaN. 반드시 엔티티 1단계 + charge 필터 합산. - typeLabel: 과세/면세/영세/비수익만 매핑, 그 외(장충=해당없음)는 원값(과세 오표시 금지).
5-1. useAllocations.byContract/byUnit/byMember(charge) — 공개 피벗 (FB-31, 2026-07-10)
이 위 cellAmount와 별개로, useAllocations는 charge 1개를 받아 축별 그룹×합계를 돌려주는 공개 피벗 3종(byContract/byUnit/byMember)도 export한다(useAllocations.js:188-217). 현재 소비처 0(export만 존재, 어떤 컴포넌트도 import하지 않음) — 그래서 §5의 cellAmount와 똑같은 "엔티티 컬럼의 g.lines는 charge 무관 전체 라인, allocate(charge)는 그 charge 라인에만 값 존재" 함정을 그대로 갖고 있었는데도 이번 웨이브 전까지 아무도 걸리지 않았다(FB-26류 "시한폭탄" — 배선되는 순간 즉발).
- 수정:
g.lines.filter(c => amounts[c.line.id] !== undefined)로 charge-무관 라인을 먼저 제외한 뒤 매핑·합산, 결과cells.length===0인 그룹은 통째로 드롭. 세 피벗 동일 패턴. - 불변식 테스트(
useAllocations.spec.js): 3개 피벗 모두Number.isNaN(g.total)===false· 각 셀amount!==undefined·Σg.total === useCharges().amountsOf(charge).chargePrincipal(브리핑이 가정한chargePrincipal직접 export는 없음 —useCharges().amountsOf(charge).chargePrincipal로 대체). - 이 3종을 새로 소비하는 화면을 만들 때는 위 필터가 이미 적용된 채로 값을 받게 되므로 별도 방어 코드 불요 — 단 빈 그룹이 배열에서 아예 빠진다(0건 그룹이 아니라 부재)는 점을 렌더 쪽에서 인지해야 한다.
6. 확장 포인트 / 주의
- 계약 = 1유닛(호실별). 멤버 다유닛 = 계약 N개. 펼침/atom = 단일 호, 멤버명 클릭만 멀티유닛 합산.
- 시드 =
demoSeed.js(15세대, 비용 EXP_SCALE 비례). 행 수 조절 =DEMO_CONFIG.householdCount. - 신규 축/시트 변경 시: 본 문서 + BE [[tenancy-axes-billing.md]] + 사용법 매뉴얼 동반 갱신(3종 의무).
- 게이트:
npm run precommit:check(DS) + vitest + build.