결재선 패널 (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 없이 상태만 박아 둔 것). 실제 화면은 파셜이 데이터로 그립니다.
- 1이채원· 팀장
- 2최급여· 인사 관리자나
- 1이채원· 팀장
- 2최급여· 인사 관리자사유: 해당 주 인력이 부족해요
구조
<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 | 보는 사람이 바꿀 권한이 있나 — 패널은 이 값 없이는 버튼을 그리지 않는다 |
패널 approvalSteps | onReplace + apsCanReplace(step) | canReplace 가 참이고 단계가 아직 처리되지 않았을 때(approvalStepReplaceable, @hereby/core/constants)만 줄 끝 「바꾸기」(.ds-btn--xs) |
모달 approvalReplace | packages/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 가드)과 결과(토스트 · 재조회 · 다른 창 신호)를 받는다 |
<!-- 상세 모달 안 — 패널에 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) | 승인·반려 버튼 :disabled | mine · 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 는 단계를 이미 읽은 상세 안 요약용) |