Skip to content

콘솔 stage 모델 — FE 메인테이너 참고

관리비 상용화 펀치리스트 Critical C-1 + Important I-7 해소. 설계 정본: docs/superpowers/specs/2026-07-09-console-stage-model-design.md. 2026-07-26 개요 예외: actual console/general은 모바일 진입 화면에서 4카드가 659px 헤더를 만들고 본문과 상태를 중복해 StageWidgetOrg를 제거했다. 같은 useConsoleStage를 MainOrg의 현재 단계 1행 요약이 읽는다. 이 요약은 이동 CTA를 두지 않으며, 업무 진입은 상단 탭이 단독 소유한다. stage 전이 기능은 각 업무 화면의 ActionBar/StageWidget에 유지된다.


1. 배경

부과·청구·수납 콘솔의 확정/저장/시작 CTA가 ~11개 화면에서 전부 dead(리터럴 disabled 또는 핸들러 없음)였고, 콘솔 일반 탭 헤더의 스테이지 위젯(4카드)도 배지·prev/next가 전부 하드코딩이었다. 이번 슬라이스가 상태를 담는 useConsoleStage 컴포저블을 신설하고 두 곳(ActionBar·Header)을 같은 소스로 배선했다.


2. useConsoleStage — 소비 방법

src/composables/useConsoleStage.js

2-1. 순수 API vs 라우트 바인딩 래퍼

1차 계약은 순수 (line, edition, stage) 인자 함수 — Vue 컴포넌트/라우터 없이 단위 테스트 가능:

js
import {
  statusOf,
  canStart,
  canConfirm,
  canReopen,
  start,
  confirm,
  reopen,
  stepForward,
  stepBack,
} from "@/composables/useConsoleStage";

statusOf("service-charge", "actual", "charging"); // 'pending' | 'progress' | 'confirmed'
canStart("service-charge", "actual", "invoicing"); // boolean
start("service-charge", "actual", "charging"); // { ok: true } | { ok: false, reason }

컴포넌트에서는 useConsoleStage() 래퍼를 쓴다. 현재 라우트의 useBillingEdition().billing에서 line/edition을 파생한다. 관리비 운영 UUID Office에서는 buildOfficeStageLine()line@officeId#periodManagementCode로 스코프해 Office뿐 아니라 정산회차도 분리한다. 인자에서 line/edition이 빠진다:

js
import { useConsoleStage } from "@/composables/useConsoleStage";

const { canStart, canConfirm, start, confirm, statusOf, stages, canReopen, stepForward, stepBack } =
  useConsoleStage();
canStart("charging"); // line/edition은 현재 라우트에서 자동 파생

게이트 로직(canStart/canConfirm/canReopen)은 순수 함수에만 있다 — 래퍼는 얇은 바인딩일 뿐, 로직을 재구현하지 않는다.

2-2. 반응성

state는 모듈 레벨 ref 싱글톤(useCharges, useMeterReadings 동형). watch(state, ..., { deep: true })가 즉시 UI와 로컬 복구본을 갱신한다. authSessionhydrateConsoleStages(userId)가 로그인 후 service_charge_stage_workflows를 읽어 운영 scope를 서버 상태로 덮어쓴다. 래퍼의 성공 전이는 consoleStageRepo.transition() RPC로 전달되고, 실패하면 같은 scope를 다시 읽어 낙관 상태를 복구한다. 순수 API와 로컬 데모 동작은 기존대로 동기식이다.


3. 소비처 — "두 UI, 한 소스"

3-1. ActionBar (시작/확정 버튼)

콘솔파일stage
부과(actual)src/components/service-charge/actual/console/charging/allocation-{unit,contract,member}/blocks/ActionBarOrg.vuecharging
부과(provisional)src/components/service-charge/provisional/console/charging/allocation/blocks/ActionBarOrg.vuecharging
청구src/components/billing/_core/console/invoicing/{unit,contract,member}/blocks/ActionBarOrg.vueinvoicing
수납src/components/billing/_core/console/collecting/{unit,contract,member}/blocks/ActionBarOrg.vuecollecting

각 파일 패턴(부과 예시, <script setup> 신설):

vue
<script setup>
import { useConsoleStage } from "@/composables/useConsoleStage";
const { canStart, canConfirm, start, confirm } = useConsoleStage();
</script>

<template>
  <button
    @click="start('charging')"
    :disabled="!canStart('charging')"
    :title="canStart('charging') ? '부과 시작' : '이미 시작되었거나 시작할 수 없음'"
  >
    시작
  </button>
  <button
    @click="confirm('charging')"
    :disabled="!canConfirm('charging')"
    :title="canConfirm('charging') ? '부과 확정' : '부과 시작 후 확정 가능'"
  >
    확정
  </button>
</template>
  • 범용 stage 모델은 별도 draft를 소유하지 않는다. 다만 actual 관리비 부과 3축은 서버 편집 스냅샷이 구현됐으므로 ChargeDraftSaveMol을 통해 중간저장 상태·명시 flush·실패 재시도를 제공한다. 이 컨트롤은 useCharges만 소비하며 stage 전이나 확정 Fact를 대신하지 않는다. 기존 무동작 more_vert는 3축 ActionBar에서 제거했다.
  • 청구/수납 ActionBar는 C-2 회계 전기 배선을 그대로 보존한다 — useGeneralLedger, useBillingJournal.invoicingVouchers/collectionVouchers, unpostedCount, postAllInvoicing/postAllCollecting, AccountingModuleToggleMol(있는 화면)은 기존 <script setup>useConsoleStage import만 추가된 것이며 변경되지 않았다.
  • invoicing/collecting 3파일(unit/contract/member)은 byte-identical(에디션 공유 컴포넌트).

3-2. 헤더 스테이지 위젯 (4카드) — StageWidgetOrg 공유 컴포넌트 (스테이지 C1-6)

2026-07-10 갱신: C1-4/I-7은 이 위젯을 5개 헤더에만 배선했고, 나머지 콘솔 헤더(부과/청구/공지/세금계산서/ 선수금 등 17개)는 여전히 복붙된 하드코딩 4카드를 그대로 두고 있었다 — 같은 화면에서 ActionBar(실 상태)와 헤더(가짜 상태)가 모순되는 drift였다. C1-6이 파생 로직 + DS 마크업을 StageWidgetOrg로 추출하고 당시 콘솔 헤더 22개 전부를 이 컴포넌트로 수렴시켰다. 2026-07-26부터 actual 개요 헤더 1개는 위젯을 의도적으로 렌더하지 않고 본문 현재 단계 요약으로 대체한다. 하드코딩 위젯은 여전히 존재하지 않는다.

공유 컴포넌트: src/components/billing/_core/console/StageWidgetOrg.vue

  • Props: defaultOpen(Boolean, 기본 false) — 카드 기본 펼침/접힘. 마이그레이션 전 헤더별로 이미 갈라져 있던 순수 표시 선호를 보존(다음 표 참고).
  • Slot: forwarding-extra (scope prop stage) — 이월 카드에 콘솔별 예외 배지를 주입하는 확장 포인트. 위젯 자체는 이 도메인을 모른다.

적용 헤더 21개(전부 import StageWidgetOrg + <StageWidgetOrg />):

파일대상defaultOpen비고
service-charge/provisional/console/general/HeaderOrg.vue관리비(provisional) 일반 탭true
billing/_core/console/collecting/{unit,contract,member}/HeaderOrg.vue수납 탭falsev-if="billing.line === 'service-charge'" 유지
billing/_core/console/invoicing/{unit,contract,member}/HeaderOrg.vue청구 탭false
billing/_core/console/advance-receipt/HeaderOrg.vue선수금·가수금 탭true
billing/_core/console/tax-invoice/HeaderOrg.vue세금계산서 탭true
service-charge/actual/console/charging/allocation-{unit,contract,member}/HeaderOrg.vue부과(actual) 유닛/계약/멤버 뷰false
service-charge/actual/console/charging/assessment/HeaderOrg.vue부과(actual) 산정 뷰false
service-charge/actual/console/notice/{general,individual}/HeaderOrg.vue공지 탭true
service-charge/actual/console/{post-adjustment,pre-adjustment}/HeaderOrg.vue정산 전/후 조정false하단 raw 탭 리스트(toPath)는 유지
service-charge/provisional/console/charging/{allocation,assessment}/HeaderOrg.vue부과(provisional)false
service-charge/provisional/console/{post-adjustment,pre-adjustment}/HeaderOrg.vue정산 전/후 조정(provisional)false

파생 로직(StageWidgetOrg.vue 내부, 단일 소스):

js
const { stages, statusOf, canStart, canConfirm, canReopen, stepForward, stepBack } =
  useConsoleStage();

const STAGE_STEPS = {
  charging: ["pending", "progress", "confirmed"],
  invoicing: ["pending", "progress", "confirmed"],
  collecting: ["pending", "progress", "confirmed"],
  forwarding: ["pending", "confirmed"], // forwarding은 진행중 뱃지 없이 대기/확정 2단
};
const STATUS_SUFFIX = { pending: "Pending", progress: "Progress", confirmed: "Confirmed" };
const summaryLabelKey = (stage) => `billing.stage.${stage}${STATUS_SUFFIX[statusOf(stage)]}`;
  • summary 배지: confirmed → badge-success-moderate, progress → badge-blue-moderate, pending → badge-gray-subtle.
  • 카드 outline: statusOf(stage) === 'progress'outline outline-solid outline-blue-500.
  • 3단 스트립 배지: 현재 step이면 confirmed → badge-green-moderate / progress → badge-blue-moderate / pending → badge-gray-moderate, 아니면 badge-gray-subtle.
  • footer: [이전]stepBack(stage) :disabled="!canReopen(stage)"; [다음]stepForward(stage) :disabled="!(canStart(stage) || canConfirm(stage))".
  • collecting/contract/HeaderOrg.vueforwarding-extra 슬롯으로 이월 anomaly 배지(isAnomalous 카운트, isCanon 게이팅)를 주입한다 — stage 모델과 무관한 기존 기능, 위젯 추출 후에도 유지.

같은 소스: ActionBar에서 start('charging')을 눌러도, 헤더에서 stepForward('charging')를 눌러도 동일한 useConsoleStage 싱글톤 상태를 변경하므로 반대쪽 UI가 즉시 반영된다(둘 다 같은 ref를 구독) — 이제 여기에 "어느 헤더에서 보든" 이 추가됐다: 22개 헤더 전부가 같은 StageWidgetOrg 인스턴스를 통해 같은 소스를 구독하므로, 한 콘솔에서 확정해도 다른 콘솔로 이동하면 즉시 같은 상태가 보인다.

신규 콘솔 헤더를 추가할 때: 이 4카드 위젯이 필요하면 반드시 <StageWidgetOrg />를 그대로 import해서 쓴다 — 다시 인라인으로 복붙하지 않는다. src/components/billing/_core/console/__tests__/StageWidgetOrg.no-inline-copy.spec.jsMIGRATED_HEADERS 목록 기반 회귀 가드 + repo 전체 HeaderOrg.vue 스캔으로 하드코딩 복붙 재발을 잡는다 — 새 헤더를 추가하면 그 목록에도 추가할 것.


4. i18n 키

모바일 헤더 overflow 계약

StageWidgetOrg를 감싸는 .disclosure-page-header-body는 좁은 화면에서 가로 스크롤을 유지하되 native scrollbar는 숨긴다. 외형은 leysys-design page-header.css 정본이며 제품 SFC에서 scrollbar override를 추가하지 않는다. Chromium/WebKit은 ::-webkit-scrollbar, Firefox는 scrollbar-width: none으로 동일하게 처리한다.


src/i18n/locales/{ko,en}.jsonbilling.stage.*:

값 (ko)
charging / invoicing / collecting / forwarding부과 / 청구 / 수납 / 미수금 연령분석
{stage}Pending / {stage}Progress / {stage}Confirmed예: chargingPending = 부과대기, chargingProgress = 부과진행, chargingConfirmed = 부과확정
prev / next이전 / 다음

forwardingProgress 키는 이번 슬라이스에서 신규 추가(누락돼 있었음 — 이월도 이론상 progress 상태 진입 가능하도록 완비, 실제 UI에서는 forwarding 스트립이 2단이라 시각적으로는 안 쓰이지만 i18n 완전성 확보).

ActionBar의 개별 버튼 라벨(청구시작/확정/수납시작 등)은 각 컴포넌트가 기존에 쓰던 키(billing.serviceCharge.actual.console.charging.allocationUnit.button.startCharging 등) 또는 리터럴 문자열을 그대로 사용 — 이번 슬라이스는 :disabled/:title/@click만 추가했다.


5. C-2(회계 전기)와의 공존

청구/수납 ActionBar는 confirm/startpostAllInvoicing/postAllCollecting(회계 전기)이 서로 독립적으로 동작한다 — 확정 여부가 전기 버튼의 활성/비활성에 영향을 주지 않고, 전기 여부가 stage 게이트에 영향을 주지 않는다. 두 기능이 상호 게이팅해야 하는지는 오너 판단 대상으로 남겨두었다(§7 확장 포인트).


6. 테스트 위치

파일대상
src/composables/__tests__/useConsoleStage.spec.js상태머신 순수 함수 — 게이트·전이·no-op·line/edition 독립·영속
src/composables/__tests__/consoleStageRepo.spec.jsSupabase 행 변환·scope 조회·전이 RPC payload
src/composables/__tests__/serviceChargeStageMigration.spec.js복합 scope·멱등성·RLS/grant·서버 전이 게이트 정적 계약
src/components/service-charge/actual/console/charging/allocation-{unit,contract}/blocks/__tests__/ActionBarOrg.spec.js부과 ActionBar 배선(actual)
src/components/service-charge/provisional/console/charging/allocation/blocks/__tests__/ActionBarOrg.spec.js부과 ActionBar 배선(provisional, actual과 독립)
src/components/billing/_core/console/invoicing/unit/blocks/__tests__/ActionBarOrg.stage.spec.js청구 ActionBar 순차 게이트 + C-2 회귀 스모크
src/components/billing/_core/console/collecting/unit/blocks/__tests__/ActionBarOrg.stage.spec.js수납 ActionBar 순차 게이트 + C-2 회귀 스모크
src/components/billing/_core/console/__tests__/StageWidgetOrg.spec.js공유 위젯 4카드 배지/outline/스트립/게이트/클릭 구동 + defaultOpen/forwarding-extra 슬롯(스테이지 C1-6, 구 HeaderOrg.spec.js 커버리지 이관처)
src/components/billing/_core/console/__tests__/StageWidgetOrg.no-inline-copy.spec.js위젯 소비 헤더 21개가 StageWidgetOrg만 사용하고 하드코딩 복붙이 재발하지 않았는지 정적 스캔 회귀 가드
src/components/billing/_core/console/collecting/contract/__tests__/HeaderOrg.spec.js수납/계약 헤더의 이월 anomaly 배지(forwarding-extra 슬롯) 보존 검증
src/components/service-charge/actual/console/general/__tests__/HeaderOrg.spec.jsactual 개요가 정적 page-header + ConsoleTabBar만 유지하고 4카드 disclosure/dead 더보기를 되살리지 않는지 검증

7. 확장 포인트 (follow-up)

  • 저장/draft: 편집 draft 모델이 코드베이스에 아예 없다. 도입 시 useConsoleStage와 별개 컴포저블(useDraft*)로 분리하고, 저장 성공 후에만 start를 호출하는 흐름을 검토.
  • 확정 시 폼 잠금: 검침(useMeterReadings)·부과(useCharges) 입력 화면에서 statusOf('charging') === 'confirmed'를 읽어 :disabled를 걸면 된다 — 게이트 인프라는 이미 있으니 각 입력 컴포넌트에서 useConsoleStage().statusOf('charging')를 구독하기만 하면 확장 가능.
  • 이월 전용 버튼: 이월(forwarding)은 read-only 리포트라 전용 ActionBar가 없다. 필요해지면 useForwarding 콘솔에 start('forwarding')/confirm('forwarding')을 배선하면 되고, 게이트(canStart가 collecting confirmed를 요구)는 이미 준비돼 있다.
  • 확인 다이얼로그: confirm/reopen 호출 지점에 <dialog> 확인 팝오버를 얹으면 된다 — 상태머신 자체는 변경 불필요(호출 시점만 다이얼로그의 확인 콜백으로 옮기면 됨).
  • C-2 상호 게이팅: 예를 들어 "확정 전에는 회계 전기 금지" 같은 규칙이 필요해지면 각 ActionBar의 postAllInvoicing/postAllCollecting 호출부에 canConfirm/statusOf 조건을 추가하면 된다(현재는 독립).
  • 최종 부과 커밋: 현재 서버 RPC는 stage 전이만 원자화한다. charging 확정과 배분 결과 동결·채권/E2 생성이 한 DB 트랜잭션이 되면 consoleStageRepo.transition()의 charging-confirm 경로를 전용 확정 RPC로 교체한다. ActionBar/Header API 표면은 유지한다.

8. 부과산정 목록 → 상세 선택 계약

DynamicTableOrg.vue의 모바일·데스크톱 부과상세 버튼은 모달을 열기 전에 useChargeItemUi().selectItem(charge.chargeCode)를 호출한다. AccountChargeDetailArt.vueselectedCodeuseCharges().byChargeCode()로 해석하고 이름·관리코드·세금구분·발생/부과 금액을 동일 canonical charge 객체에서 계산한다. 상세 시트에 예시 항목명·금액을 하드코딩하지 않는다.

회귀 테스트:

  • blocks/__tests__/DynamicTableOrg.spec.js: 행별 상세 버튼이 해당 chargeCode를 선택하는지 확인
  • overlays/__tests__/AccountChargeDetailArt.spec.js: 선택 항목 전환과 금액 파생, 레거시 예시값 제거 확인