Skip to content

결재선 패널 (Approval steps) ​

같은 문서는 어디서 열어도 같은 결재선

휴가 · 연장근무 · 외근 · 인센티브 신청에는 결재선(결재자 1~5명의 순서)이 붙습니다. 결재선 패널은 그 단계를 번호 · 이름 · 당시 직책 · 상태 · 처리 시각 · 대신 처리 · 반려 사유 순으로 세로 타임라인으로 보여 주는 공용 부품이에요. 팀장 앱 상세 모달, 관리자 연차·외근 상세, 직원 앱 신청 상세와 결재할 문서 상세가 같은 파셜 하나를 include 해서, 신청자와 결재자가 서로 다른 그림을 보는 일이 없습니다. 목록 칸에는 같은 데이터를 한 줄로 줄인 compact 모드를 씁니다.

규칙 (강제) · 코딩 에이전트용

결재 단계를 보여 주는 자리의 강제 규칙. 상세·마크업·옵션은 아래 본문.

  • 결재 단계는 이 패널 하나로만 그린다. 상세 화면에 단계 타임라인을 따로 만들지 말 것(직원 앱의 고정 3칸 「진행 상황」은 단계 줄이 없는 예전 신청에만 남는다). 호스트는 x-data="approvalSteps({ getState, … })" 로 감싸고 packages/components/html/approval-steps.html 을 include 한다.
  • 데이터는 호스트가 approvalFlow.steps 한 번으로 가져온다. 패널은 서버를 부르지 않는다. 목록 행의 「내 차례 · n/N」 은 목록 응답의 approvalState 요약으로 그리고, 상세를 열 때만 단계를 읽는다.
  • 상태는 다섯 가지뿐 — 대기(waiting) · 확인 중(pending) · 승인(approved) · 반려(rejected) · 건너뜀(skipped). 라벨·색은 @hereby/core/constants 의 approvalStepStatusView() 가 정본이다(확인 중 yellow · 승인 green · 반려 red · 건너뜀 gray · 대기 light-gray). 화면에서 다른 말(「처리됨」 · 「완료」)로 바꾸지 말 것.
  • 예전 방식(단계 줄 없음)은 한 줄 안내다 — 「결재자 지정 없이 접수된 문서예요. 승인 권한이 있는 사람 누구나 처리해요.」 빈 타임라인이나 가짜 1단계를 그리지 않는다.
  • 대신 처리는 처리한 사람 이름으로 드러낸다 — 「최급여 님이 대신 처리」. 결재자 이름을 처리한 사람으로 바꿔치기하지 말 것(단계의 결재자는 그대로, decidedBy 가 따로다).
  • 반려 사유는 그 단계 안에 .ds-approval-steps__reason 으로 보인다. 알림 본문에는 싣지 않는다(잠금화면 노출 금지).
  • 직원 앱은 size: 'lg' + labelSize: 'md' — 글자 13px 하한 규칙. 데스크톱(팀장 앱 · 관리자)은 기본값.
  • 행 상태 · 버튼 활성 · 확인창 문구도 공용 규칙으로 — approvalRowState() · approvalRowActionable() · approvalProgressLabel() · approvalDecisionHint() (@hereby/core/constants). 화면마다 「내 차례」 판정을 다시 쓰지 말 것.
  • 승인·반려 결과와 오류는 공용 핸들러로 — 성공은 서버 message 를 토스트에(decisionResultMessage), FORBIDDEN · CONFLICT · 네트워크는 실패가 아니라 「상태가 바뀜」이라 다시 읽고 안내만 한다(handleDecisionError, @hereby/components). 재승인을 유도하는 오류 다이얼로그를 띄우지 말 것.
  • 모양은 전부 .ds-approval-steps*(design-system.css) — 마크업에 인라인 스타일 · 색 유틸을 붙이지 말 것. :has() 없음.
  • 다크 모드(2026-10-19) — 결재선 패널(approval-steps.html)은 이미 시멘틱 토큰만 써서 바뀐 것이 없고, 결재자 교체 모달(approval-replace-modal.html)의 면을 토큰으로 옮겼다. 이 페이지에는 Playground 가 없어 다크 미리보기가 없다 — 직원 앱 「결재할 문서」에서 확인한다.

단일 출처 ​

파트파일
CSS 정의 (정본)packages/static/styles/design-system.css (.ds-approval-steps*)
컴포넌트packages/components/src/components/ApprovalSteps.ts (approvalSteps)
파셜packages/components/html/approval-steps.html
화면 공통 규칙(행 상태 · 라벨 · 문구)packages/core/src/constants/approval-state.ts
결과 · 오류 공용 처리packages/components/src/approval-decision.ts
서버approvalFlow.steps · 목록 응답 approvalState (approval-flow.service.ts)

모양 ​

아래는 DS 클래스를 그대로 쓴 정적 마크업이에요(Alpine 없이 상태만 박아 둔 것). 실제 화면은 파셜이 데이터로 그립니다.

결재선 · 2단계
  1. 1
    이채원· 팀장
    승인2026-10-07 09:12
  2. 2
    최급여· 인사 관리자나
    확인 중지금 이분 차례예요

결재선 · 2단계반려
  1. 1
    이채원· 팀장
    승인2026-10-07 09:12 · 최급여 님이 대신 처리
  2. 2
    최급여· 인사 관리자
    반려2026-10-07 09:40
    사유: 해당 주 인력이 부족해요

결재선
ⓘ결재자 지정 없이 접수된 문서예요. 승인 권한이 있는 사람 누구나 처리해요.

구조 ​

html
<div x-data="approvalSteps({ getState: () => detailApproval, getLoading: () => approvalLoading, viewerId: () => myId })">
  <!-- include "../../packages/components/html/approval-steps.html" -->
</div>
.ds-approval-steps[.ds-approval-steps--lg][.ds-approval-steps--compact]
├─ .ds-approval-steps__title        「결재선 · N단계」 + 결과 칩(최종 승인 · 반려)
├─ .ds-approval-steps__loading      ds-spinner.ds-spinner--sm + 「결재 단계를 불러오는 중이에요」
├─ .ds-approval-steps__legacy       예전 방식 한 줄(아이콘 ic-info-circle)
├─ .ds-approval-steps__compact      한 줄 요약(목록 칸)
└─ ol.ds-approval-steps__list
   └─ li.ds-approval-steps__item.is-<status>
      ├─ .ds-approval-steps__dot    단계 번호(상태별 색)
      └─ .ds-approval-steps__body
         ├─ .ds-approval-steps__who     이름 · 직책(__role) · 「나」 칩 · 줄 끝 「바꾸기」(__replace, 조건부)
         ├─ .ds-approval-steps__meta    상태 칩(.ds-label) · 시각 · 대신 처리
         ├─ .ds-approval-steps__replaced  바뀐 이력 한 줄(「최급여 님에서 바뀜 · 김관리 님 · 시각」, 바뀐 단계만)
         └─ .ds-approval-steps__reason  반려 사유

옵션 ​

옵션뜻기본
getState() => { isLegacy, currentStepOrder, steps[] } | null — approvalFlow.steps 응답 그대로—
getLoading() => boolean — 불러오는 중이면 스피너false
viewerId보는 사람 id(값 또는 함수) — 그 사람 단계에 「나」없음
compact한 줄 요약만(「1/2단계 · 이채원 님 확인 중」 · 「2/2 완료」 · 「반려됨」 · 「결재자 지정 없음」)false
hideTitle제목 줄 숨김false
size'md' · 'lg'(직원 앱 — 이름 14px · 설명 13px)'md'
labelSize상태 칩 크기 'xs' · 'sm' · 'md''sm'
onReplace(step) => void — 주면 바꿀 수 있는 단계 줄 끝에 「바꾸기」 버튼. 호스트는 결재자 바꾸기 모달을 연다없음

상태 ​

단계 상태점칩설명 줄
waiting 대기테두리만light-gray 「대기」없음
pending 확인 중주황 채움yellow 「확인 중」「지금 이분 차례예요」
approved 승인초록 채움(뒤 선도 초록)green 「승인」시각 · (대신 처리면) 「○○○ 님이 대신 처리」
rejected 반려빨강 채움red 「반려」시각 · 아래에 사유
skipped 건너뜀점선 테두리 · 이름 흐림gray 「건너뜀」없음

제목 옆 결과 칩은 단계 줄만 보고 정합니다(approvalOutcomeFromSteps) — 반려가 하나라도 있으면 「반려」, 전부 승인이면 「최종 승인」, 확인 중이 남았거나 취소(전부 건너뜀)면 없음.

결재자가 바뀐 단계(replacedFromName 이 있는 줄)는 상태 줄 아래에 .ds-approval-steps__replaced 한 줄 — 「최급여 님에서 바뀜 · 김관리 님 · 2026. 10. 07. 15:10」 — 을 둡니다. 어느 앱에서 열어도 같은 줄입니다.

결재자 바꾸기 ​

아직 처리되지 않은 단계(확인 중 · 대기)의 결재자를 그 종류 조직 전체 범위 승인자 · 시스템 관리자(대신 처리와 같은 기준)가 다른 사람으로 바꿉니다. 퇴사 · 장기 부재로 결재가 멈춘 문서를 풀어 주는 관리자 기능이라 팀장 앱 상세(= 관리자 결재 메뉴 iframe)와 관리자 연차 · 외근 상세에만 있고, 직원 앱에는 없습니다.

역할어디하는 일
서버 approvalFlow.steps응답 canReplace보는 사람이 바꿀 권한이 있나 — 패널은 이 값 없이는 버튼을 그리지 않는다
패널 approvalStepsonReplace + apsCanReplace(step)canReplace 가 참이고 단계가 아직 처리되지 않았을 때(approvalStepReplaceable, @hereby/core/constants)만 줄 끝 「바꾸기」(.ds-btn--xs)
모달 approvalReplacepackages/components/html/approval-replace-modal.html · ApprovalReplace.ts일반 모달 한 벌. 후보는 approvalFlow.replaceCandidates(이 신청의 후보 − 신청자 − 아직 처리되지 않은 단계의 결재자)를 대상 선택기에 넣어 한 명(multiple: false) 고르고, 확인창(approvalReplaceHint) 뒤 approvalFlow.replaceApprover
호스트requestApprovalReplace · bindApprovalReplaceHost (@hereby/components)패널의 onReplace 에서 requestApprovalReplace({ step, ref }) 로 열고, bindApprovalReplaceHost({ onState, onReplaced }) 로 열림/닫힘(상세 모달 ESC 가드)과 결과(토스트 · 재조회 · 다른 창 신호)를 받는다
html
<!-- 상세 모달 안 — 패널에 onReplace 만 더한다 -->
<div x-data="approvalSteps({ getState: () => detailApproval, getLoading: () => detailApprovalLoading,
                             viewerId: () => $store.hereby.currentUser?.id, onReplace: (step) => openApprovalReplace(step) })">
  <!-- include "../../packages/components/html/approval-steps.html" -->
</div>

<!-- 루트 안에 한 번 — 모달은 자기 스코프(approvalReplace 컴포넌트)라 창 이벤트로만 연결된다 -->
<!-- include "../../packages/components/html/approval-replace-modal.html" -->

규칙:

  • 모달은 호스트 루트에 펼치지 않습니다(이벤트 연결). 거대한 Alpine 루트 리터럴에 ...fn() 을 넣으면 TS 가 this 를 쓰는 게터 수십 개를 순환(TS7023)으로 판정해 앱 빌드가 깨집니다. 그래서 hereby:approval-replace-open · hereby:approval-replace-state · hereby:approval-replaced 세 창 이벤트로 잇습니다.
  • 상세 모달의 ESC 핸들러는 호스트 플래그(approvalReplaceOpen)를 먼저 봅니다. 모달 위 모달이라 같은 ESC 로 둘이 함께 닫히면 안 됩니다. 바꾸기 모달 쪽은 $nextTick 으로 닫아 상세 핸들러가 어느 순서로 돌아도 열림을 참으로 봅니다.
  • 확인창 문구 · 바꿀 수 있는 단계 · 성공 문구는 공용 규칙입니다 — approvalReplaceHint(core) · approvalStepReplaceable(core, 서버 isReplaceableStep 과 같은 함수) · approvalReplaceMessage(components). 화면에서 다시 쓰지 않습니다.
  • 알림은 새 결재자에게만 갑니다(확인 중 단계일 때 바로, 대기 단계는 차례가 됐을 때). 예전 결재자 · 신청자에게는 알리지 않고, 예전 결재자의 결재함에서는 자동으로 빠집니다.

같이 쓰는 공용 규칙 ​

목록 행과 버튼은 패널이 아니라 @hereby/core/constants 의 순수 함수로 정합니다. 세 앱이 같은 함수를 부르므로 「내 차례」 판정이 화면마다 갈라지지 않아요.

함수쓰는 곳돌려주는 것
approvalStepReplaceable(status)패널 「바꾸기」 · 서버 바꾸기 검사확인 중 · 대기만 true
approvalReplaceHint(step, toName)결재자 바꾸기 확인창「최급여 님 → 정소율 님으로 바꿔요. 정소율 님에게 결재할 문서 알림이 가고, 최급여 님의 결재함에서는 빠져요.」(대기 단계는 「차례가 되면 … 알림이 가요」)
approvalRowState(state, isPending)목록 행mine · delegate · others · legacy · closed
approvalRowActionable(row)승인·반려 버튼 :disabledmine · delegate · legacy 만 true
approvalProgressLabel(state, isPending)「결재」 열「내 차례 · 1/2」 · 「최급여 님 확인 중 · 2/2」 · 「결재자 지정 없음」 · 「2/2 완료」 · 「반려됨」
approvalNextLabel(state)결재 열 꼬리표「→ 최급여」
approvalStatusDetail(state, isPending)상태 라벨 뒤「2/2단계 최급여 님」
approvalDecisionHint(state, decision)승인 확인창「승인하면 최급여 님에게 넘어가요」 · 「최종 승인돼요」 · 대신 처리면 앞에 「이채원 님 대신 처리돼요 · 」
approvalOthersTurnMessage(state)비활성 버튼 툴팁「지금은 이채원 님 차례예요」

처리 결과와 오류는 @hereby/components 의 decisionResultMessage(result) · handleDecisionError(error, { refresh }) 로 통일합니다. FORBIDDEN · CONFLICT · 네트워크는 목록을 다시 읽고 안내(showAlert)만 하고, 그 밖의 오류만 showError 입니다.

쓰는 곳 ​

화면모드
팀장 앱 휴가 · 연장근무 · 외근 · 대시보드 상세 모달(= 관리자 결재 메뉴 iframe)기본 + onReplace(결재자 바꾸기)
관리자 근태 현황 › 연차 · 외근 상세 모달기본 + onReplace(결재자 바꾸기)
직원 앱 신청·결재 현황 › 내 신청 상세 · 결재할 문서 상세size: 'lg' · labelSize: 'md'
목록 칸(결재 열)approvalProgressLabel (패널 compact 는 단계를 이미 읽은 상세 안 요약용)