다크모드
수납 as-of 타임머신(유닛·계약·멤버) — FE 메인테이너
독자: FE 메인테이너/AI. 조회 기준일 공유 상태·DataToolBar 컨트롤·DynamicTableOrg 소비·과거 읽기전용 게이팅·배지/주석·계약/멤버 탭 소비·상세 시트 as-of 캡처·확장 포인트. 정본 spec:
docs/superpowers/specs/2026-06-24-collecting-asof-timemachine-design.md. 관련: [[collecting-aggregate-fullpay.md]] · [[tenancy-axes-billing-tables.md]] · [[collecting-detail-sheet.md]].
파일 위치
| 역할 | 파일 |
|---|---|
| 핵심 컴포저블 | src/composables/useCollectingAsOf.js |
| 순수 함수 (tree-shakeable) | asOfInflow, fifoCollect (동일 파일에서 named export) |
| DataToolBar (DatePicker + [현재로]) — 유닛 | src/components/billing/_core/console/collecting/unit/blocks/DataToolBarOrg.vue |
| 유닛 탭 테이블 (as-of 소비·게이팅) | src/components/billing/_core/console/collecting/unit/blocks/DynamicTableOrg.vue |
| DataToolBar (DatePicker + [현재로]) — 멤버 | src/components/billing/_core/console/collecting/member/blocks/DataToolBarOrg.vue |
| 멤버 탭 테이블 (as-of 소비·게이팅) | src/components/billing/_core/console/collecting/member/blocks/DynamicTableOrg.vue |
| DataToolBar (DatePicker + [현재로]) — 계약 | src/components/billing/_core/console/collecting/contract/blocks/DataToolBarOrg.vue |
| 계약 탭 테이블 (as-of 소비·게이팅) | src/components/billing/_core/console/collecting/contract/blocks/DynamicTableOrg.vue |
| 테스트 | src/composables/__tests__/useCollectingAsOf.spec.js |
공유 asOf 상태
useCollectingAsOf는 모듈 싱글톤 ref(파일 최상위 const asOf = ref(REFERENCE_DATE))를 사용한다. DataToolBar와 DynamicTableOrg가 같은 컴포저블을 호출하면 동일 ref를 공유한다. 별도 provide/inject 불필요. 탭을 전환해도 싱글톤 ref가 유지되므로 기준일은 유닛·계약·멤버 탭 전환 사이에서 초기화되지 않는다.
js
// DataToolBarOrg.vue (유닛·계약·멤버 탭 공통)
const { asOf, isPast, setAsOf, resetToPresent } = useCollectingAsOf();
// DynamicTableOrg.vue — 유닛 탭
const { isPast, asOf, unitRowsAsOf, excludedReceiptCount } = useCollectingAsOf();
// DynamicTableOrg.vue — 계약 탭
const { isPast, asOf, contractRowsAsOf, excludedReceiptCount } = useCollectingAsOf();
// DynamicTableOrg.vue — 멤버 탭
const { isPast, asOf, memberRowsAsOf, excludedReceiptCount } = useCollectingAsOf();asOf 공개 API
| 이름 | 타입 | 설명 |
|---|---|---|
asOf | Ref<string> | 현재 기준일 (YYYY-MM-DD) |
isPast | ComputedRef<boolean> | asOf < REFERENCE_DATE |
setAsOf(iso) | function | 설정 — 미래 날짜는 REFERENCE_DATE로 클램프 |
resetToPresent() | function | asOf → REFERENCE_DATE |
unitRowsAsOf() | function → UnitRow[] | 현재=forwardingByUnit 위임 / 과거=계약 합산 재유도 |
contractRowsAsOf() | function → ContractRow[] | 현재=forwardingByContract 위임 / 과거=contractRowAsOf 직접 매핑(atom flat) |
memberRowsAsOf() | function → MemberRow[] | 현재=forwardingByMember 위임 / 과거=memberHoldings 기반 계약 합산(유닛 동형) |
excludedReceiptCount(axis, key) | function → number | 기준일 이후 해당 집계 단위 유효 수납 건수. axis: 'unit'|'member'|'contract', key: 해당 코드 |
DataToolBar 컨트롤 (DataToolBarOrg.vue)
html
<div class="flex flex-row items-center gap-x-2">
<span class="body-xs text-dimmed whitespace-nowrap">조회 기준일</span>
<DatePicker v-model="asOfDate" />
<button
v-if="isPast"
@click="resetToPresent"
class="button button-neutral-subtle button-sm whitespace-nowrap"
>
현재로
</button>
</div>asOfDate=computed양방향: get →parseDate(asOf.value)/ set →setAsOf(YYYY-MM-DD).[현재로]버튼은v-if="isPast"— 과거 모드일 때만 노출.- 일괄 수납 버튼(
:disabled="isPast")도 DataToolBar에서 같이 게이팅.
DynamicTableOrg as-of 소비
collectings 행 분기
js
const collectings = computed(() => {
if (isCanon.value) {
const rows = isPast.value
? unitRowsAsOf().map(r => ({ ...r, id: 'fwd-' + r.key, ... }))
: buildCanonRows()
...
}
})현재 모드(isPast = false) → buildCanonRows() (forwardingByUnit 위임). 과거 모드(isPast = true) → unitRowsAsOf() (as-of 재유도).
펼침 계약 행 as-of 미수
js
const contractAsOfOutstanding = (cc) => {
if (!isPast.value) return null
const row = forwardingByContract().find(r => r.key === cc)
if (!row) return 0
const ordered = [...row.periods].sort(...)
const collected = fifoCollect(ordered, asOfInflow(cc, asOf.value))
return ordered.reduce((s, p) => s + max(0, billed − adjusted − collected[p.label]), 0)
}펼침 행 총청구금 셀: formatNumber(isPast ? (contractAsOfOutstanding(m.contractCode) ?? m.amount) : m.amount).
제외 건수 집계
js
const excludedTotal = computed(() =>
isPast.value
? collectings.value.reduce((s, r) => s + (r.key ? excludedReceiptCount(r.key) : 0), 0)
: 0,
);배지 및 주석
html
<div v-if="isCanon && isPast" class="w-full flex items-center gap-x-2 flex-wrap">
<span class="badge badge-md badge-warning-moderate rounded-full">
조회 기준일 {{ asOf }} · 과거 시점(읽기전용)
</span>
<span v-if="excludedTotal > 0" class="body-xs text-dimmed">
기준일 이후 수납 {{ excludedTotal }}건 제외됨
</span>
</div>- 테이블 위 전체 폭 flex 행 — isCanon && isPast 일 때만 렌더.
- 배지:
badge-warning-moderate(주의 색조). - 제외 건수 주석:
excludedTotal > 0일 때만 추가 표시.
과거 읽기전용 게이팅 — 3진입점
과거 모드(isPast = true)에서 수납 액션을 막는 곳이 세 군데 있다. 모두 :disabled="isPast" 또는 클릭 핸들러 가드.
1. 집합 행 [완납]
html
<button
...
:disabled="collecting.totalOutstanding <= 0 || isPast"
class="button button-primary-subtle button-xs"
>
완납
</button>isPast OR totalOutstanding <= 0 이면 비활성.
2. 펼침 계약 행 [수납] 버튼 + Enter 확정
html
<button
v-else
@click="openContractInline(m)"
:disabled="contractOutstanding(m.contractCode) <= 0 || isPast"
class="button button-primary-subtle button-xs"
>
수납
</button>열린 인라인 입력 상태에서도 Enter·✓ 버튼에 isPast 가드:
html
<input ... :disabled="isPast" @keydown.enter.prevent="isPast ? null : commitContractInline(m)" />
<button @click="isPast ? null : commitContractInline(m)" :disabled="isPast" ...></button>3. 일괄 완납 [선택 완납]
html
<button
@click="commitBatch()"
:disabled="!selectedRows.length || isPast"
class="button button-primary-bold button-sm"
>
선택 완납
</button>계약 탭 as-of 소비
계약 탭은 atom flat 구조(합산 없음). contractRowsAsOf()가 forwardingByContract()의 소스 스왑 역할을 한다.
js
// 계약 탭 DynamicTableOrg.vue
const { isPast, asOf, contractRowsAsOf, excludedReceiptCount } = useCollectingAsOf()
const collectings = computed(() => {
if (isCanon.value) {
return isPast.value ? contractRowsAsOf() : buildCanonContractRows()
}
...
})계약 탭 과거 읽기전용 게이팅
계약 탭은 집합 행이 없으므로 유닛/멤버의 [완납]·일괄 완납 게이팅 대신 atom 인라인 수납만 잠근다.
| 진입점 | 게이팅 |
|---|---|
| atom 행 [수납] 버튼 | `:disabled="... |
| 인라인 input Enter 확정 | @keydown.enter.prevent="isPast ? null : commitContractInline(m)" |
| 인라인 ✓ 버튼 | @click="isPast ? null : commitContractInline(m)" :disabled="isPast" |
| 계약명 클릭 → 상세 시트 | 과거에도 열림 허용 — 시트가 useCollectingActions를 통해 목록 asOf를 고정 캡처하여 읽기전용으로 표시 |
멤버 탭 as-of 소비
멤버 탭은 유닛 탭과 동형 — 집합 [완납]·펼침 [수납]·인라인·일괄 완납 전부 isPast 잠금.
js
// 멤버 탭 DynamicTableOrg.vue
const { isPast, asOf, memberRowsAsOf, excludedReceiptCount } = useCollectingAsOf()
const collectings = computed(() => {
if (isCanon.value) {
const rows = isPast.value
? memberRowsAsOf().map(r => ({ ...r, id: 'fwd-' + r.key, ... }))
: buildCanonMemberRows()
...
}
})memberRowsAsOf()는 unitRowsAsOf()와 소스(memberHoldings vs unitOccupants)만 다르고 집계 로직·반환 구조·게이팅 패턴은 동일하다. 유닛 탭 DynamicTableOrg 코드를 템플릿으로 재사용할 수 있다.
멤버 탭 excludedReceiptCount 소비
js
const excludedTotal = computed(() =>
isPast.value
? collectings.value.reduce((s, r) => s + (r.key ? excludedReceiptCount("member", r.key) : 0), 0)
: 0,
);유닛 탭은 excludedReceiptCount('unit', r.key), 멤버 탭은 excludedReceiptCount('member', r.key), 계약 탭은 excludedReceiptCount('contract', r.key).
반응성 흐름
DataToolBar setAsOf(iso) [유닛·계약·멤버 탭 공통 — 싱글톤]
→ asOf (ref, 모듈 싱글톤) 변경
→ isPast (computed) 재평가
→ collectings (computed, 각 탭 DynamicTableOrg) — isPast.value 분기 재실행
유닛: unitRowsAsOf() / 계약: contractRowsAsOf() / 멤버: memberRowsAsOf()
→ excludedTotal (computed) — isPast 분기 재실행 (axis별 excludedReceiptCount 호출)
→ 배지 v-if 토글
→ 게이팅 :disabled 바인딩 재평가별도 watch 없이 모두 computed 의존성 체인으로 자동 반응. 탭 전환 시 각 탭의 DynamicTableOrg가 독립적으로 같은 싱글톤 asOf를 구독하므로 기준일이 초기화되지 않는다.
테스트 (useCollectingAsOf.spec.js)
| 케이스 | 검증 |
|---|---|
| 기준일=현재 동치 | unitRowsAsOf().total === forwardingByUnit().total 전수 |
| 과거 기준일 미수 증가 | 기준일 이후 수납 recordReceipt → asofTotal ≥ presentTotal |
| isPast + 미래 클램프 | setAsOf('2099-01-01') → asOf === REFERENCE_DATE, isPast === false |
| excludedReceiptCount | 기준일 이후 수납 1건 → excludedReceiptCount('101') ≥ 1 |
| FIFO 충당 | 2차수 15000원 → 오래된 차수 full, 나머지 차수 partial |
| asOfInflow 날짜 슬라이스 | 11-30 기준 → 12-20 수납 제외 / 취소·미식별 제외 / appliedLateFee 차감 |
상세 시트 as-of 연동 (useCollectingActions + SheetCollectingDetailArt)
asOf 고정 캡처(seed) + readOnly
useCollectingActions(selectionRef) 는 selectedCollecting(현재 선택 행)을 받는다. 계약명 클릭으로 시트가 열릴 때(selectionRef 변경 watch) 그 순간 목록의 isPast·listAsOf를 고정 캡처한다.
js
// src/composables/useCollectingActions.js
const asOf = ref(REFERENCE_DATE);
const readOnly = ref(false);
const { asOf: listAsOf, isPast } = useCollectingAsOf();
watch(selectionRef, () => {
readOnly.value = isPast.value;
asOf.value = isPast.value ? listAsOf.value : REFERENCE_DATE;
});readOnly = true→ 목록이 과거 모드일 때만(시트 열린 후 목록 날짜를 바꿔도 시트 readOnly 불변).asOf = listAsOf.value→ 목록 기준일 그대로detailcomputed에 전달 →useCollectingDetail.detailFor(axis, key, asOf)→buildOneas-of 재산정.REFERENCE_DATE복구 명칭: 구DEMO_AS_OF→REFERENCE_DATE로 일원화(현재 적용됨).
detail 파생
js
const detail = computed(() => {
const sel = selectionRef.value;
if (!sel) return null;
return detailFor(sel.axis, sel.key, asOf.value);
});asOf.value가 과거이면 buildOne이 contractRowAsOf 경로로 차수별 outstanding·collected·status·연체료를 재산정한다(BE 문서 참조).
상세 시트 읽기전용 게이팅 (SheetCollectingDetailArt.vue)
readOnly prop은 아래 네 지점에서 소비된다.
| 컴포넌트 / 요소 | 게이팅 방식 | 효과 |
|---|---|---|
CollectingActionOrg | v-if="!readOnly" | 수납 확정 패널 전체 숨김 |
AdvanceBalanceOrg | :read-only="readOnly" | 선수금 자동충당 토글 잠금 |
ReceiptHistoryOrg | :read-only="readOnly" | 수납 취소·환급 버튼 잠금 |
ReceivableAdjustmentsOrg | :read-only="readOnly" | 조정 승인·취소 버튼 잠금 |
과거 시점 배지는 시트 zone-top에 표시:
html
<div v-if="readOnly" class="badge badge-sm badge-warning-moderate rounded-full">
조회 기준일 {{ asOf }} · 과거 시점(읽기전용)
</div>확장 포인트
- 계약 탭 ✅ 구현 완료:
contractRowsAsOf()named export + 계약 탭 DynamicTableOrg 소비. atom 인라인만 잠금, 계약명 클릭 시트는 과거에도 열림. - 멤버 탭 ✅ 구현 완료:
memberRowsAsOf()named export + 멤버 탭 DynamicTableOrg 소비. 유닛 탭 미러(집합 [완납]·펼침 [수납]·인라인·일괄 전부 isPast 잠금). - 상세 시트 as-of 연동 ✅ 구현 완료:
useCollectingActionswatch(selectionRef) → asOf 고정 캡처·readOnly=true.SheetCollectingDetailArt4지점 게이팅. 연체료 금액 포함 그 시점 상세 표시. - 기준일 URL 동기화:
setAsOf를 router query 파라미터와 연동하면 URL 공유 가능. 싱글톤 ref를 URL 기반 초기화로 대체하거나watchEffect로 동기화. - DatePicker 범위 제한: 현재는 미래 날짜를
setAsOf내부에서 클램프. UI DatePicker에maxValueprop을 REFERENCE_DATE로 설정하면 선택 불가로 개선 가능. - prior-only 계약 처리: 멤버·유닛 탭 모두
memberHoldings/unitOccupants가 활성 배분 line 기반이므로 부과 종료 계약은 과거 모드에서 잡히지 않음. BE 모델에서 비활성 계약 매핑 보존 필요(한계 §1 참조).