다크모드
FE 메인테이너 — 청구 상세 시트
독자: FE 메인테이너/AI. "이 시트를 이어 개발하려면 무엇을 알아야 하나"를 정의한다. 정본 spec:
docs/superpowers/specs/2026-06-25-invoicing-detail-sheet-functionalization-design.md. BE 계약:docs/handoff/backend/invoicing-detail-sheet.md.
1. 진입 (라우트 없음 — 시트 오버레이)
전용 페이지/라우트는 없다. 상세는 시트 오버레이(
SheetInvoicingDetailArt, dialog idsheet-invoicing-detail-art)로 연다.3축 모두 배선 완료 — 각 축의
DynamicTableOrg(invoicing 하위)에openInvoicingDetail(invoicing)이 정의돼 있고, canon(service-charge actual) 행의 총청구금 버튼 클릭 시selectedBilling세팅 + 시트 열기가 동시에 일어난다.축 파일 selectedBilling세팅contract invoicing/contract/blocks/DynamicTableOrg.vue{ ...a, axis: 'contract', key: a.contractCode }unit invoicing/unit/blocks/DynamicTableOrg.vue{ ...invoicing, axis: 'unit', key: invoicing.id }(id = unitCode)member invoicing/member/blocks/DynamicTableOrg.vue{ ...invoicing, axis: 'member', key: invoicing.id }(id = memberCode)인라인 펼침(멤버/유닛 브레이크다운)은 행 셰브론(›) —
toggleExpand(invoicing.id). 총청구금 클릭과 역할이 분리돼 있다(3차 T3 해소).공유 ref:
src/components/billing/_core/console/overlays/selectedBilling.js.
2. 컴포넌트 트리
SheetInvoicingDetailArt.vue (시트 루트, dialog id "sheet-invoicing-detail-art")
├─ sheet-header: "청구상세" + close 버튼
├─ zone-top: 계약명+기간배지 / 관리코드 우상단 (vm.header)
├─ zone-middle (v-if vm.current):
│ ├─ 당기분 부과금 card (disclosure-md) (vm.current)
│ │ └─ sections v-for: 과세/면세/영세/부채/자본
│ │ └─ rows table-static
│ ├─ 이월 미납분 card (disclosure-md) (vm.forward)
│ │ └─ sections v-for (이월 outstanding 세구분) / 없으면 note만
│ ├─ 합계 부과금 card (disclosure-md) (vm.grandTotal)
│ │ └─ sections v-for (= 당기 + 이월 병합)
│ └─ 분개 미리보기 card (disclosure-md, v-if vm.journal)
│ └─ table-static: 계정 / 차변 / 대변 + tfoot 합계
└─ sheet-footer: (빈 푸터 — 현재 액션 없음)파일 위치: src/components/billing/_core/console/invoicing/overlays/SheetInvoicingDetailArt.vue.
3. 소비 컴포저블 · 데이터 흐름
| 컴포저블/모듈 | 역할 |
|---|---|
selectedBilling | 진입점→시트 선택 공유 ref {axis, key, contractCode, unitName, memberName, start, end} |
useInvoicingDetail | detailFor(selection) — axis-aware 배분·분개 조립 뷰모델 파생; 내부 resolve() + combineJournal() |
useAllocations | invoicingByContract/Unit/Member() (3축 집계), lines, contracts, formatAmount |
useBillingJournal | vouchersInvoicing() — E2 청구확정 전표 목록 |
billingNature | 성격 레지스트리 모듈 — withVat(byComp, date) named export로 공급가액→공급대가 VAT 파생(분개 없을 때 폴백) |
흐름
DynamicTableOrg (contract / unit / member 축)
openInvoicingDetail / openAtomDetail
→ selectedBilling.value = { axis: 'contract'|'unit'|'member', key, ... }
+ command="show-modal" commandfor="sheet-invoicing-detail-art"
↓
SheetInvoicingDetailArt
vm = computed(() => detailFor(selectedBilling.value))
cards = computed(() => [당기, 이월, 합계]) ← vm.current 존재 시만
useInvoicingDetail.detailFor(selection)
1. resolve(selection) → { byComp, supplyTotal, ccs, axisTitle }
axis='contract': row = invoicingByContract().find(cc === key), ccs=[key]
axis='unit': row = invoicingByUnit().find(unitCode === key),
ccs = lines.filter(unitCode===key).map(contractCode) (다계약)
axis='member': row = invoicingByMember().find(memberCode === key),
ccs = contracts.filter(memberCode===key).map(contractCode) (다계약)
2. journal = combineJournal(ccs)
→ 구성 전표(bj-inv-*)를 side|accountCode별 합산 → { lines, debitSum, creditSum }
→ N=1이면 단일 전표와 동등. 전표 없으면 null.
3. vat = journal != null
? journal.debitSum - supplyTotal (역산 — 다계약 반올림 드리프트 제거)
: withVat(byComp, REFERENCE_DATE).부가세 (폴백)
4. sections = buildSections(byComp, vat) ← 5섹션 [과세/면세/영세/부채/자본]
5. forward = forwardingByCompFor(axis, key) 이월 차수 byComponent 합산
→ fwdVat = fwdByComp.부가세, fwdSupply = Σ(나머지 키)
→ forward.sections = buildSections(fwdByComp, fwdVat) (없으면 total 0 + note)
6. grandTotal = 당기 + 이월 (원금 키 합산 + VAT 별도 합산 → buildSections)
→ { header, current:{supplyTotal,vat,total,sections},
forward:{supplyTotal,vat,total,sections,note}, grandTotal:{supplyTotal,vat,total,sections},
journal:{lines,debitSum,creditSum}|null }selection또는key가 없거나 해당 행을 못 찾으면 →EMPTY(전 필드 null) 반환, throw 없음.vm.currentnull이면 시트 body에 "청구 대상을 선택하면 상세가 표시돼요" 안내만 표시.
4. DS 패턴 (CLAUDE.md 준수)
- 시트 =
dialog ... sheet sheet-right sheet-lg sheet-width-3xl sheet-inset-edged sheet-filled sheet-divide-y.sheet-width-3xl(48rem) — 세로 폼/상세 집중 폭 기준. - 시트 body =
bg-neutral-subtle. 카드 래퍼 =bg-neutral-minimal+disclosure disclosure-md disclosure-inset-edged disclosure-divide-y. - summary zone-right = Σ(공급대가) —
formatAmount(card.data.total)/formatAmount(section.total). 접힌 상태에서도 합계 정보 손실 없음(카드 규율2). - 섹션 내 행 테이블 =
table table-sm table-static table-divide-y w-full. - 분개 카드도 동일
disclosure-md패턴, tbody 행 + tfoot 합계행. 이월 미납분카드는 이월 outstanding 세구분 sections를 렌더(당기 카드와 동형). 이월 없으면 total 0 + 안내note(body-xs).- 현재 sheet-footer 액션 없음 — 청구 확정·전송 액션은 이 시트 범위 밖(후속).
5. 게이팅
useModuleSubscription(accountingEnabled) 게이팅 없음 — 분개 미리보기 카드는vm.journal(null이면 숨김)으로만 제어된다.vm.journal이 null인 경우: 해당 contract의'bj-inv-'+contractCode전표를vouchersInvoicing()에서 찾지 못한 경우(데이터 갭 또는 non-canon). 현재 서비스차지 actual 에디션의 seed 계약에는 전표가 존재하므로 정상 시나리오에서는 journal 카드가 항상 표시된다.
6. 선택 ref 구조
js
// contract 축 (openAtomDetail(a)가 세팅)
{
axis: 'contract',
key: a.contractCode, // detailFor → invoicingByContract().find(cc===key)
contractCode: a.contractCode,
unitName: a.unitName,
memberName: a.memberName,
start: a.start, // periodLabel 표시용
end: a.end
}
// unit 축 (openInvoicingDetail(invoicing), invoicing.id = unitCode)
{
axis: 'unit',
key: invoicing.id, // detailFor → invoicingByUnit().find(unitCode===key)
...invoicing // unitDisplayName, billingPeriod 등 테이블 행 필드 포함
}
// member 축 (openInvoicingDetail(invoicing), invoicing.id = memberCode)
{
axis: 'member',
key: invoicing.id, // detailFor → invoicingByMember().find(memberCode===key)
...invoicing
}header.title = [unitName, memberName].filter(Boolean).join(' ') or axisTitle(unit.name / member.name) 폴백. header.periodLabel = start/end 형태(start·end 둘 다 있을 때만). header.accountCode = selection.contractCode ?? key (unit/member 축에선 key = unitCode/memberCode).
7. 확장 포인트
- 이월분 표시 (✅ 2026-06-27 구현):
vm.forward는useForwarding의 이월 차수byComponent(충당 후 잔여 outstanding) 합산으로 세구분 분해된다(buildSections재사용).grandTotal = current + forward. ⚠ roll-forward(월말 미수→차월 이월 자동승격) 런타임 엔진은 미구현 — 정적 데모는 시드 priorPeriods 고정. 실 BE에서 월 경계 모델 필요(forward 표시 자체는 무관·완성). - 청구 확정·전송 액션: 현재 sheet-footer가 비어 있다. "전송" 버튼·"청구 확정" 버튼을 footer에 추가 시
postingStatus·sendStatus상태 연동이 필요하다. - 세금계산서 발행 연계:
vm.header.accountCode(contractCode or unitCode)로tax-documents모듈과 연결해 해당 계약/유닛의 계산서 발행 이력을 탭으로 추가 가능. - 딥링크: 현재 URL 불변. 공유가 필요하면
selectedBilling을 쿼리(?detail=u-101)와 동기화하는 라우트 가드 추가(현재 범위 밖).
8. 테스트 위치
| 대상 | 테스트 |
|---|---|
| 뷰모델 불변식 — 3축 전체 (공급대가·섹션Σ·분개 cross-check·이월·균형·null-safe·5섹션) | src/composables/__tests__/useInvoicingDetail.spec.js |
| 이월 표시 배선 + E1 개시분개 불변식 (a~h: 섹션Σ·세구분합·E1 대차·net+vat·grand=당기+이월·3축·재무상태표한정·회귀) | src/composables/__tests__/forwardingCarryoverDisplay.spec.js |
유닛/멤버 집계 불변식 — Σ(sections[].total) === current.total === journal.debitSum === creditSum (N>1 포함) | 同上 |
| N=1 퇴화 — 유닛 단일계약 = contract 축과 수치 동일 검증 | 同上 |
| 분개 전표 소스 | src/composables/__tests__/useBillingJournal.spec.js |
| byComponent 원천 | src/composables/__tests__/useAllocations.spec.js |
| VAT 파생 | src/composables/__tests__/billingNature.spec.js |
| 총청구금 클릭 → sheet 진입 배선(unit/member) | src/components/billing/_core/console/invoicing/unit/blocks/__tests__/InvoicingTotal.wire.spec.js |
| 총청구금 클릭 → sheet 진입 배선(member) | src/components/billing/_core/console/invoicing/member/blocks/__tests__/InvoicingTotal.wire.spec.js |
실행: npx vitest run. Playwright 시나리오(수동): 부과 콘솔 → 유닛/멤버/계약 탭 → canon 행 총청구금 클릭 → 시트 오버레이(URL 불변) · 5섹션 + 분개 카드 표시(다계약 합산) · 접기/펼치기 Σ 일치 · 시트 닫기 → 목록 유지, console 0.
2026-06-26 — 유닛/멤버 총청구금 집계 상세 시트 배선 (3차 결함 T3 최종 해소)
- 3차 T3 워크어라운드: 총청구금 클릭 =
toggleExpand(인라인 펼침)로 임시 처리. sheet는 열려도 unit/member key 미전달 → 빈 상태. - 최종 해소:
useInvoicingDetail.detailFor에axis분기(unit/member) 추가 +combineJournal(ccs)다계약 합산 → unit/member 집계 뷰모델 완전 구현.DynamicTableOrg(unit·member) 총청구금 클릭 =openInvoicingDetail(invoicing)→selectedBilling = { axis, key }+commandfor="sheet-invoicing-detail-art". 인라인 펼침은 행 셰브론(›)(toggleExpand)으로 이동(역할 분리). - VAT 역산: N>1 집계 시
journal.debitSum - supplyTotal(per-contract 반올림 드리프트 제거). N=1이면withVat()결과와 수치 동일. - 불변식(3축 공통):
Σ(sections[].total) === current.total === journal.debitSum === journal.creditSum.useInvoicingDetail.spec.js에서 매 배포마다 검증. - 테스트:
useInvoicingDetail.spec.js(불변식·N=1·EMPTY) +InvoicingTotal.wire.spec.js(unit/member 배선).