承認フロー(Approval Flow)— ステータス遷移管理
このコンポーネントについて
下書き→申請中→承認済み/差戻しの状態遷移を、ステータスバッジと状態に応じた操作ボタンの表示制御で管理する承認フローUIの実装例です。経費申請・稟議・各種届け出など、業務システムで最頻出のワークフロー画面を題材にしています。遷移ルールは「現在の状態→実行できる操作」を1つのJavaScriptオブジェクト(遷移マップ)で定義し、操作ボタンの表示と遷移先をそこから導出します(ステータスの表示名と配色は、別の対応表とCSSで管理します)。申請者/承認者の視点切替つきで、同じ状態でも役割によって押せる操作が変わる「権限による出し分け」の動きも確認できます。
- 遷移マップ駆動 — 「現在の状態 → 許可される操作(ラベル・遷移先・実行できる役割・コメント要否)」を1つのオブジェクトで一元管理し、
if分岐を画面のあちこちに書かない。遷移を追加するときはマップに1行足すだけ(新しいステータスを増やす場合は、表示名の対応表とバッジ配色のCSSもあわせて更新する) - 視点切替(申請者/承認者) — セグメントボタンで役割を切り替えると、同じ状態でも表示される操作ボタンが変わる。ログインユーザーの役割で出し分ける実務のコード構造をそのまま再現
- 差戻しコメント必須 — 「差し戻す」はモーダルでコメント入力を必須にし、未入力では確定できない。コメントは差戻し中のカードと操作履歴に表示される
- 操作履歴の自動記録 — 遷移のたびに「日時・操作者・遷移内容」を履歴リストへ追加し、新しい順に表示する
- ステータスバッジの配色 — 下書き=グレー/申請中=青/承認済み=緑/差戻し=赤。業務システム定番の配色マッピング
実装のポイント・注意点
最大のポイントは、遷移可否を if (status === 'pending' && role === 'approver') のような分岐で書かず、遷移マップ(データ)として持つことです。分岐で書くと遷移を追加したときにボタン表示・遷移処理の各所を漏れなく直す必要があり、修正漏れが起きやすくなります。マップに集約すれば遷移の追加は1行で済み、「どの状態から何ができるか」の一覧性も得られます。なお、ステータスの表示名は対応表(statusLabels)、バッジの配色はCSSの data-status セレクタで管理しているため、新しいステータスを増やすときはこの2か所もあわせて更新してください。
もう1つの注意点は、フロント側のボタン出し分けはあくまでUX(利用者が迷わず操作できるようにするための画面上の制御)だということです。ブラウザ上の制御は改ざんできるため、実務では遷移可否・権限の確定判断を必ずサーバー側でも検証してください。また、差戻しコメントはユーザー入力をそのまま表示するため、描画には textContent を使い、コメントに含まれるHTMLを文字として扱うことでXSS(入力されたHTMLやスクリプトが画面上で実行されてしまう攻撃)を防いでいます。一覧画面側でステータスをまとめて変更するUIはテーブル(選択行の一括ステータス変更)で紹介しています。
HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。
デモ
7月出張旅費の精算
操作履歴
サンプルソース
3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js
— 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>承認フロー — ステータス遷移管理</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="apf-wrap">
<!-- 視点切替 -->
<div class="apf-role-switch" role="group" aria-label="視点切替">
<button type="button" class="apf-role-btn is-active" data-role="applicant" aria-pressed="true">申請者</button>
<button type="button" class="apf-role-btn" data-role="approver" aria-pressed="false">承認者</button>
</div>
<!-- 申請カード -->
<div class="apf-card">
<div class="apf-card-header">
<span class="apf-status" id="apf-status" data-status="draft">下書き</span>
<h3 class="apf-card-title">7月出張旅費の精算</h3>
</div>
<dl class="apf-card-meta">
<div><dt>金額</dt><dd>¥34,600</dd></div>
<div><dt>申請日</dt><dd>2026-07-17</dd></div>
<div><dt>申請者</dt><dd>田中 一郎</dd></div>
</dl>
<p class="apf-reject-comment" id="apf-reject-comment" hidden></p>
<div class="apf-actions" id="apf-actions">
<!-- 操作ボタンは遷移マップからJavaScriptで動的生成 -->
</div>
</div>
<!-- 操作履歴 -->
<section class="apf-history">
<h4 class="apf-history-title">操作履歴</h4>
<ul class="apf-history-list" id="apf-history-list"></ul>
</section>
<!-- 差戻しコメントモーダル -->
<div class="apf-overlay" id="apf-overlay" hidden>
<div class="apf-dialog" role="dialog" aria-modal="true" aria-labelledby="apf-dialog-title">
<h2 class="apf-dialog-title" id="apf-dialog-title">差戻しコメント</h2>
<label class="apf-dialog-msg" for="apf-comment-input">差戻しの理由を入力してください(必須)。</label>
<textarea class="apf-dialog-textarea" id="apf-comment-input" rows="3"
placeholder="例)領収書の添付が不足しています"></textarea>
<div class="apf-dialog-footer">
<button type="button" class="apf-cancel-btn" id="apf-cancel">キャンセル</button>
<button type="button" class="apf-action-btn apf-action-btn--danger" id="apf-reject-confirm" disabled>差し戻す</button>
</div>
</div>
</div>
</div>
<div class="demo-controls">
<button class="reset-btn" type="button" onclick="resetDemo()">リセット</button>
</div>
<script src="./script.js"></script>
</body>
</html>
body {
font-family: sans-serif;
background: #F7F9FC;
padding: 24px;
}
.apf-wrap {
width: 100%;
max-width: 520px;
}
/* --- 視点切替セグメント --- */
.apf-role-switch {
display: inline-flex;
border: 1.5px solid #D0D7E0;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.apf-role-btn {
padding: 8px 20px;
font-size: 13px;
background: #fff;
color: #5A6A7A;
border: none;
cursor: pointer;
font-family: inherit;
}
.apf-role-btn + .apf-role-btn {
border-left: 1.5px solid #D0D7E0;
}
.apf-role-btn.is-active {
background: #2B7FE8;
color: #fff;
}
/* --- 申請カード --- */
.apf-card {
background: #fff;
border: 1px solid #E2E8F0;
border-radius: 8px;
padding: 20px;
}
.apf-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.apf-card-title {
margin: 0;
font-size: 16px;
color: #1F2A37;
}
/* ステータスバッジ(配色は data-status 属性で切り替える) */
.apf-status {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.apf-status[data-status="draft"] { background: #F3F4F6; color: #6B7280; }
.apf-status[data-status="pending"] { background: #DBEAFE; color: #1E40AF; }
.apf-status[data-status="approved"] { background: #D1FAE5; color: #065F46; }
.apf-status[data-status="rejected"] { background: #FEE2E2; color: #991B1B; }
.apf-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin: 0;
}
.apf-card-meta > div {
display: flex;
align-items: baseline;
gap: 8px;
}
.apf-card-meta dt {
font-size: 12px;
color: #9AA5B4;
}
.apf-card-meta dd {
margin: 0;
font-size: 13px;
color: #1F2A37;
}
/* 差戻しコメント表示 */
.apf-reject-comment {
background: #FEF2F2;
border-left: 3px solid #EF4444;
border-radius: 0 4px 4px 0;
padding: 10px 12px;
margin: 12px 0 0;
font-size: 13px;
color: #7F1D1D;
}
.apf-reject-comment[hidden] {
display: none;
}
/* --- 操作ボタン --- */
.apf-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.apf-action-btn {
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.apf-action-btn:hover {
opacity: 0.85;
}
.apf-action-btn--primary { background: #2B7FE8; color: #fff; }
.apf-action-btn--danger { background: #EF4444; color: #fff; }
.apf-action-btn--neutral { background: #fff; color: #5A6A7A; border: 1.5px solid #D0D7E0; }
.apf-action-btn--danger:disabled {
background: #E5E7EB;
color: #9CA3AF;
cursor: not-allowed;
opacity: 1;
}
.apf-actions-empty {
margin: 0;
font-size: 13px;
color: #9AA5B4;
}
/* --- 操作履歴 --- */
.apf-history {
margin-top: 20px;
}
.apf-history-title {
margin: 0 0 8px;
font-size: 13px;
color: #5A6A7A;
}
.apf-history-list {
list-style: none;
margin: 0;
padding: 0;
}
.apf-history-list li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 2px;
border-top: 1px solid #EEF1F5;
font-size: 13px;
}
.apf-history-meta {
font-size: 11px;
color: #9AA5B4;
}
.apf-history-text {
color: #1F2A37;
}
.apf-history-comment {
padding-left: 12px;
font-size: 12px;
color: #B91C1C;
}
/* --- 差戻しコメントモーダル --- */
.apf-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
/* display: flex を持つ要素なので hidden を明示的に打ち消す */
.apf-overlay[hidden] {
display: none;
}
.apf-dialog {
background: #fff;
width: calc(100% - 32px);
max-width: 400px;
border-radius: 8px;
padding: 20px;
animation: apf-pop 0.15s ease-out;
}
@keyframes apf-pop {
from { transform: scale(0.96); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.apf-dialog-title {
margin: 0 0 8px;
font-size: 16px;
color: #1F2A37;
}
.apf-dialog-msg {
display: block;
margin: 0 0 10px;
font-size: 13px;
color: #5A6A7A;
}
.apf-dialog-textarea {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: 1.5px solid #D0D7E0;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.apf-dialog-textarea:focus {
outline: none;
border-color: #2B7FE8;
}
.apf-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.apf-cancel-btn {
padding: 9px 18px;
font-size: 13px;
background: #fff;
color: #5A6A7A;
border: 1.5px solid #D0D7E0;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
}
/* --- リセットボタン --- */
.demo-controls {
margin-top: 12px;
}
.reset-btn {
padding: 6px 16px;
font-size: 13px;
color: #5A6A7A;
background: #fff;
border: 1.5px solid #D0D7E0;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s, border-color 0.15s;
}
.reset-btn:hover {
background: #F4F6F9;
border-color: #9AA5B4;
}
// 遷移マップ: 現在の状態 → 許可される操作(ボタン表示・遷移・モーダル要否をすべてここから導出する)
const transitions = {
draft: {
submit: { label: '申請する', to: 'pending', role: 'applicant', variant: 'primary' }
},
pending: {
approve: { label: '承認する', to: 'approved', role: 'approver', variant: 'primary' },
reject: { label: '差し戻す', to: 'rejected', role: 'approver', variant: 'danger', requiresComment: true },
withdraw: { label: '取り下げる', to: 'draft', role: 'applicant', variant: 'neutral' }
},
rejected: {
resubmit: { label: '再申請する', to: 'pending', role: 'applicant', variant: 'primary' }
},
approved: {} // 終端: どの役割も操作できない
};
const statusLabels = { draft: '下書き', pending: '申請中', approved: '承認済み', rejected: '差戻し' };
const roleNames = { applicant: '田中 一郎(申請者)', approver: '佐藤 花子(承認者)' };
// 画面の状態(描画はすべてここから導出する)
let state;
// DOM参照
const statusBadge = document.getElementById('apf-status');
const actionsArea = document.getElementById('apf-actions');
const rejectCommentEl = document.getElementById('apf-reject-comment');
const historyList = document.getElementById('apf-history-list');
const roleButtons = document.querySelectorAll('.apf-role-btn');
const overlay = document.getElementById('apf-overlay');
const commentInput = document.getElementById('apf-comment-input');
const rejectConfirmBtn = document.getElementById('apf-reject-confirm');
const cancelBtn = document.getElementById('apf-cancel');
// モーダル確定時に実行する遷移を一時保持
let pendingAction = null;
// モーダルを開く前にフォーカスされていた要素(閉じたときに戻す)
let lastFocusedEl = null;
// 現在時刻を「HH:MM」で整形(デモ用の簡易表示。実務ではサーバー記録の日時を使う)
function formatTime(date) {
const h = String(date.getHours()).padStart(2, '0');
const m = String(date.getMinutes()).padStart(2, '0');
return h + ':' + m;
}
// 履歴エントリを先頭に追加する
function addHistory(text, comment) {
state.history.unshift({
time: formatTime(new Date()),
actor: roleNames[state.role],
text: text,
comment: comment || ''
});
}
// 遷移を実行する(履歴追加 → 状態更新 → 再描画)
function applyTransition(action, comment) {
const fromLabel = statusLabels[state.status];
const toLabel = statusLabels[action.to];
addHistory(fromLabel + ' → ' + toLabel + '(' + action.label + ')', comment);
state.status = action.to;
// 差戻し以外へ遷移したらコメント表示をクリアする
state.rejectComment = (action.to === 'rejected') ? comment : '';
render();
}
// 操作ボタンのクリック処理(コメント必須の操作はモーダルを経由する)
function onActionClick(action) {
if (action.requiresComment) {
openModal(action);
} else {
applyTransition(action);
}
}
// 描画: バッジ・視点・操作ボタン・コメント・履歴をすべて state から再構築する
function render() {
// ステータスバッジ
statusBadge.dataset.status = state.status;
statusBadge.textContent = statusLabels[state.status];
// 視点セグメントの選択状態(見た目と aria-pressed をセットで更新する)
roleButtons.forEach(function (btn) {
const active = btn.dataset.role === state.role;
btn.classList.toggle('is-active', active);
btn.setAttribute('aria-pressed', String(active));
});
// 差戻しコメント表示
if (state.rejectComment) {
rejectCommentEl.textContent = '差戻しコメント: ' + state.rejectComment;
rejectCommentEl.hidden = false;
} else {
rejectCommentEl.hidden = true;
}
// 操作ボタン(遷移マップを現在の視点でフィルタして生成する)
actionsArea.textContent = '';
const actions = Object.values(transitions[state.status]).filter(function (a) {
return a.role === state.role;
});
if (actions.length === 0) {
const p = document.createElement('p');
p.className = 'apf-actions-empty';
p.textContent = 'この視点で実行できる操作はありません';
actionsArea.appendChild(p);
} else {
actions.forEach(function (action) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'apf-action-btn apf-action-btn--' + action.variant;
btn.textContent = action.label;
btn.addEventListener('click', function () { onActionClick(action); });
actionsArea.appendChild(btn);
});
}
// 操作履歴(新しい順。textContent で描画し innerHTML は使わない)
historyList.textContent = '';
state.history.forEach(function (entry) {
const li = document.createElement('li');
const meta = document.createElement('span');
meta.className = 'apf-history-meta';
meta.textContent = entry.time + ' ' + entry.actor;
const text = document.createElement('span');
text.className = 'apf-history-text';
text.textContent = entry.text;
li.appendChild(meta);
li.appendChild(text);
if (entry.comment) {
const c = document.createElement('span');
c.className = 'apf-history-comment';
c.textContent = 'コメント: ' + entry.comment;
li.appendChild(c);
}
historyList.appendChild(li);
});
}
// --- 差戻しコメントモーダル ---
function openModal(action) {
pendingAction = action;
lastFocusedEl = document.activeElement;
commentInput.value = '';
rejectConfirmBtn.disabled = true;
overlay.hidden = false;
commentInput.focus();
}
function closeModal() {
pendingAction = null;
overlay.hidden = true;
// モーダルを開く前の要素へフォーカスを戻す
if (lastFocusedEl && document.contains(lastFocusedEl)) {
lastFocusedEl.focus();
}
lastFocusedEl = null;
}
// コメントが空白のみの間は確定ボタンを押せない
commentInput.addEventListener('input', function () {
rejectConfirmBtn.disabled = commentInput.value.trim() === '';
});
rejectConfirmBtn.addEventListener('click', function () {
if (!pendingAction) return;
// モーダルを開いた後に状態が変わっていないか、その遷移がまだ許可されているかを再検証する
const action = pendingAction;
const allowed = Object.values(transitions[state.status]).some(function (a) {
return a === action && a.role === state.role;
});
const comment = commentInput.value.trim();
closeModal();
if (!allowed || comment === '') return;
applyTransition(action, comment);
});
cancelBtn.addEventListener('click', closeModal);
// モーダル表示中のキー操作: Escで閉じる・Tabはダイアログ内に閉じ込める(簡易フォーカストラップ)
document.addEventListener('keydown', function (e) {
if (overlay.hidden) return;
if (e.key === 'Escape') {
closeModal();
return;
}
if (e.key !== 'Tab') return;
const focusables = overlay.querySelectorAll('textarea, button:not(:disabled)');
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (document.activeElement === last || !overlay.contains(document.activeElement))) {
e.preventDefault();
first.focus();
}
});
// 視点切替(状態は変えず「誰として見ているか」だけを切り替える)
roleButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
state.role = btn.dataset.role;
render();
});
});
// 初期化・リセット共通処理
function resetDemo() {
state = {
status: 'draft',
role: 'applicant',
rejectComment: '',
history: [
{ time: formatTime(new Date()), actor: roleNames.applicant, text: '下書きを作成', comment: '' }
]
};
closeModal();
render();
}
resetDemo();
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ステータスの追加や役割の変更など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# 承認フロー(ステータス遷移管理)UI 作成依頼
## 概要
下書き→申請中→承認済み/差戻しの状態遷移を、ステータスバッジと操作ボタンの表示制御で管理する承認フローUIを実装してください。題材は経費申請1件の承認画面です。
## 要件
- ステータスは「下書き・申請中・承認済み・差戻し」の4種類。現在のステータスを色付きバッジで表示する(下書き=グレー・申請中=青・承認済み=緑・差戻し=赤)
- 遷移ルールは「現在の状態 → 実行できる操作(ボタンラベル・遷移先・実行できる役割・コメント要否)」を1つのJavaScriptオブジェクト(遷移マップ)で定義し、操作ボタンの生成と遷移をこのマップから導出する。状態ごとのif分岐を画面のあちこちに書かない。ステータスの表示名と配色は、別の対応表オブジェクトとCSSで管理してよい
- 画面上部のセグメントボタンで「申請者/承認者」の視点を切り替えられる。表示される操作ボタンは現在の状態と視点の組み合わせで決まる
- 遷移は5つ。下書き→申請する(申請者)→申請中/申請中→承認する(承認者)→承認済み/申請中→差し戻す(承認者・コメント必須)→差戻し/申請中→取り下げる(申請者)→下書き/差戻し→再申請する(申請者)→申請中。承認済みは終端で、どの役割も操作できない
- 「差し戻す」はモーダルでコメント入力を必須にする(空白のみの間は確定ボタンをdisabledにする)。入力されたコメントは差戻し中のカードに表示し、再申請したら非表示に戻す
- 遷移を実行するたびに「時刻・操作者・遷移内容」を操作履歴リストの先頭に追加する。差戻しの履歴にはコメントも表示する
- 実行できる操作がない状態×視点では「この視点で実行できる操作はありません」と表示する
- リセットボタンで初期状態(下書き・申請者視点・履歴は「下書きを作成」の1件のみ)に戻す
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要
## 動作詳細
画面の状態(現在ステータス・選択中の視点・差戻しコメント・履歴)を1つのオブジェクトで管理し、描画関数がそこからバッジ・操作ボタン・履歴をすべて再構築してください。
操作ボタンは遷移マップの該当エントリを役割でフィルタして動的生成し、クリック時はマップの遷移先に従って状態を書き換えてから再描画してください。
差戻しモーダルはEscキーとキャンセルボタンで閉じられるようにし、確定時のみ遷移を実行してください。
モーダル表示中はフォーカスをモーダル内に閉じ込めて背面を操作できないようにし、確定時には現在の状態と役割でその遷移がまだ許可されているかを再検証してください。
ボタン・履歴・コメントの描画は createElement と textContent で行い、innerHTML に値を結合しないでください。
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。