MAS-358: Cockpit 折りたたみ式左サイドバーナビゲーション(GAS スプレッドシートメニュー廃止)
概要
| 項目 | 内容 |
|---|---|
| 案件 ID | MAS-358 |
| 案件名 | Cockpit 折りたたみ式左サイドバーナビゲーション(GAS スプレッドシートメニュー廃止) |
| カテゴリ | UX / UI 刷新 |
| 優先度 | P2 ★★★(コックピットを主動線に据える UX 統一・スプレッドシートメニュー廃止) |
| 関連案件 | MAS-232(Vite+React SPA 基盤・既実装)/ MAS-057(F-57 コックピット)/ MAS-067(F-67 マルチイヤー)/ MAS-356(推奨先行・Tremor UI コンポーネント基盤)/ MAS-357(後続・Firebase Hosting 移行) |
| 推奨実装順 | MAS-356 → MAS-358 → MAS-357(356 で Tremor サイドバーコンポーネントを導入後に 358 を実装すると CSS スクラッチが不要。357 移行前に 358 Phase 2 でメニュー廃止を完了しておく) |
| 新規追加ファイル | webapp_client/src/cockpit/CockpitNavSidebar.tsx(左サイドバーナビゲーションコンポーネント) |
| 変更ファイル | webapp_client/src/CockpitApp.tsx(レイアウト変更)/ webapp_client/src/MultiyearApp.tsx(同)/ webapp_client/src/styles/cockpit.css(サイドバー CSS)/ Phase 2: 100_config/101_sys_config.js(createMenu 呼び出し削除) |
| 実装ステータス | 未着手 |
課題と目的
現状の問題
Google Sheets のネイティブメニュー(ui.createMenu())でシステム操作を提供している。現在のメニュー構成:
| カテゴリ | 項目数 |
|---|---|
🚀 BizLP(メインランチャー) | 7 |
💾 バックアップ | 5 |
📋 サイドバー: 📊 マート更新 | 13 |
📋 サイドバー: 🔧 開発・設定 | 7 |
📋 サイドバー: 📒 経理業務 (RPA / Action) | 9 |
📋 サイドバー: 🔍 消込・マッチング | 9 |
📋 サイドバー: 📝 費用登録 | 1 |
📋 サイドバー: ⚙️ メンテナンス | 4 |
📋 サイドバー: 🔧 マイグレーション | 16 |
📋 サイドバー: 🔄 開発用 (dev) | 1 |
📋 サイドバー: 🧪 テスト | 1 |
この構造の問題点:
- スプレッドシート依存: コックピット Web App (
?view=cockpit) を主動線とする設計方針に反し、操作がスプレッドシートの Google 製メニューバーに埋もれている - 発見しにくい: カテゴリが多く、目的の操作を探すのに時間がかかる
- コンテキスト断絶: スプレッドシートとコックピットを行き来する操作フローが非効率
- SaaS 化困難: 将来の MAS-357 Firebase Hosting 移行時、GAS ネイティブメニューは機能しない
解決策
コックピットページ左端に 折りたたみ可能なナビゲーションサイドバーを追加し、スプレッドシートメニューを廃止する。
- 前提:
getInitialStateForSpa('cockpit')の戻り値にmenuDefinition: Constants.MENU_DEFINITIONが既に含まれている(300_ui/302_spa_bridge.js:196確認済)— GAS 側変更不要 - Phase 1: 左サイドバー UI を実装(メニューは両方使える過渡期)
- Phase 2:
101_sys_config.jsのcreateMenu呼び出しを削除してスプレッドシートメニューを廃止
実装仕様
Phase 1 — 左サイドバーコンポーネント実装(フロントエンドのみ)
1-A. レイアウト変更 (CockpitApp.tsx / MultiyearApp.tsx)
cockpit-wrap を 2 カラムレイアウト cockpit-layout でラップする。
// Before
<div className="cockpit-wrap">
<header className="cockpit-header ...">...</header>
...panels...
</div>
// After
<div className="cockpit-layout">
<CockpitNavSidebar
menuDefinition={state?.menuDefinition ?? null}
isDev={state?.isDev ?? false}
hasTestRunner={state?.hasTestRunner ?? false}
onRun={handleNavRun}
/>
<div className="cockpit-wrap">
<header className="cockpit-header ...">...</header>
...panels...
</div>
</div>
MultiyearApp.tsx も同様に cockpit-layout でラップして同じ CockpitNavSidebar を共有する。
1-B. CockpitNavSidebar.tsx コンポーネント設計
ファイル: webapp_client/src/cockpit/CockpitNavSidebar.tsx
interface CockpitNavSidebarProps {
menuDefinition: MenuCategory[] | null;
isDev: boolean;
hasTestRunner: boolean;
onRun?: (funcName: string, label: string) => void; // GAS 関数呼び出しコールバック
}
内部状態:
| 状態 | 型 | 保存先 | 初期値 |
|---|---|---|---|
isOpen | boolean | localStorage['cockpit_nav_open'] | true |
openCategories | Set<string> | localStorage['cockpit_nav_cats'] (JSON) | 全カテゴリ |
runningFunc | string | null | state のみ | null |
runResult | {ok: boolean, msg: string} | null | state のみ | null |
表示仕様:
- 開いた状態 (幅 240px): ハンバーガーボタン(☰)+ カテゴリ見出し + アイテムリスト
- 閉じた状態 (幅 48px): ハンバーガーボタン(☰)のみ(カテゴリは非表示)
- ホバー時: CSS
title属性でツールチップ表示
- ホバー時: CSS
- カテゴリ: アコーディオン形式(クリックで展開/折りたたみ)
- アイテム: ラベル(
item.label)+ description(item.description、開いた状態でのみ表示) - セパレータ:
item.separator === trueの場合<hr>を表示
条件フィルタリング:
MENU_DEFINITION の条件フィールド | フィルタリングロジック |
|---|---|
category.privileged === true | Phase 1 では表示(簡易化)。Phase 2 で isPrivileged フラグを bootstrap に追加して非特権ユーザーに非表示 |
item.condition === 'isDev' | isDev が false の場合は非表示 |
item.condition === 'hasTestRunner' | hasTestRunner が false の場合は非表示 |
category.source === 'sidebar' | 表示(絞り込みなし・全カテゴリ表示) |
カテゴリラベルの整形:
📋 サイドバー: 📊 マート更新→ 閉じた状態では📊アイコンのみ表示- 正規表現
📋 サイドバー: (.+)でカテゴリ名をショートラベルに変換 - 変換できない場合は元のラベルをそのまま表示
1-C. GAS 関数呼び出し実装
コックピット Web App は google.script.run が使用可能(GAS Web App context)。
function handleNavRun(funcName: string, label: string) {
if (runningFunc) return; // 二重実行防止
setRunningFunc(funcName);
setRunResult(null);
// @ts-expect-error GAS global
google.script.run
.withSuccessHandler(() => {
setRunningFunc(null);
setRunResult({ ok: true, msg: `✅ ${label} が完了しました` });
// 3 秒後にメッセージをクリア
setTimeout(() => setRunResult(null), 3000);
})
.withFailureHandler((e: { message: string }) => {
setRunningFunc(null);
setRunResult({ ok: false, msg: `❌ ${label} が失敗しました: ${e.message}` });
})
[funcName](); // 動的呼び出し
}
注意事項:
funcNameはホワイトリストではなくmenuDefinition由来のため、信頼済みデータとして扱う(XSS/Injection リスクなし)- GAS は
google.script.runの戻り値非対応(void 扱い)のためwithSuccessHandlerで完了を検知 - 同時実行防止:
runningFunc !== nullの間は他のボタンをdisabledに
1-D. CSS 設計 (cockpit.css)
/* レイアウト */
.cockpit-layout {
display: flex;
flex-direction: row;
min-height: 100vh;
}
/* サイドバー */
.cockpit-nav-sidebar {
flex: 0 0 240px;
width: 240px;
transition: width 0.2s ease;
background: #1e2330;
color: #e0e6f0;
overflow-y: auto;
overflow-x: hidden;
position: sticky;
top: 0;
max-height: 100vh;
}
.cockpit-nav-sidebar.is-closed {
width: 48px;
flex: 0 0 48px;
}
/* メインコンテンツは残り幅を占有 */
.cockpit-nav-sidebar + .cockpit-wrap {
flex: 1 1 auto;
min-width: 0;
}
/* ハンバーガーボタン */
.cockpit-nav-toggle {
width: 100%;
height: 48px;
background: transparent;
border: none;
color: #e0e6f0;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* カテゴリ見出し */
.cockpit-nav-cat {
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #8899bb;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
}
/* アイテム */
.cockpit-nav-item {
width: 100%;
padding: 7px 14px;
background: transparent;
border: none;
color: #c8d4ea;
font-size: 13px;
text-align: left;
cursor: pointer;
display: flex;
gap: 6px;
align-items: flex-start;
line-height: 1.4;
}
.cockpit-nav-item:hover { background: rgba(255,255,255,0.08); }
.cockpit-nav-item:disabled { opacity: 0.5; cursor: not-allowed; }
.cockpit-nav-item.is-running { opacity: 0.7; }
/* 実行結果トースト */
.cockpit-nav-result {
padding: 8px 12px;
font-size: 12px;
border-radius: 4px;
margin: 6px;
}
.cockpit-nav-result.ok { background: #1e4d30; color: #7ee8a2; }
.cockpit-nav-result.fail { background: #4d1e1e; color: #e88787; }
1-E. menuDefinition の受け渡し
現在 CockpitApp.tsx は getInitialStateForSpa の結果から cockpitBootstrap のみを利用しており、menuDefinition を破棄している。state オブジェクトを保持して menuDefinition を CockpitNavSidebar に渡す。
// CockpitApp.tsx の useEffect 内
const state = s as InitialStateForSpa;
setMenuDefinition(state.menuDefinition); // 追加
setBoot(state.cockpitBootstrap);
setEnvName(state.envName);
setIsDev(state.isDev);
setHasTestRunner(state.hasTestRunner);
Phase 2 — GAS スプレッドシートメニュー廃止
Phase 1 の動作確認完了後に実施。
2-A. 101_sys_config.js の createMenu 呼び出し削除
100_config/101_sys_config.js の onOpen / setupMenu_ 関数で Constants.MENU_DEFINITION.forEach(function(catDef) { ... menu.addToUi(); }) しているブロックを削除する。
削除対象: 101_sys_config.js:325-347 の Constants.MENU_DEFINITION.forEach ループ全体。
保持するもの: onOpen 関数自体(将来的に必要になる場合があるため)、ただし中身は空にする。
2-B. 002_constants.js の MENU_DEFINITION 保持方針
MENU_DEFINITION は引き続き 302_spa_bridge.js の getInitialStateForSpa が参照するため 削除しない。MENU_DEFINITION はコックピット左サイドバーのデータソースとして機能し続ける。
ただし category.source: 'sidebar' フィールドは現状のコックピット向けフィルタで使用しないため、将来的に source: 'cockpit_nav' に変更して使い分けることも検討余地あり(Phase 2 時点では変更不要)。
アーキテクト指示事項
Phase 1 実装前の必須調査
CockpitApp.tsx:76のcallApi('getInitialStateForSpa', 'cockpit')戻り値確認:state.menuDefinitionがnullでないことを dev 環境で実機確認。302_spa_bridge.js:142-147の条件分岐(Constants.MENU_DEFINITIONが undefined の場合は null を返す)が通っていないことを grep で確認。MultiyearApp.tsxのmenuDefinition取得状況確認:callApi('getInitialStateForSpa', 'multiyear')もmenuDefinitionを返しているか302_spa_bridge.js:196を参照して確認。cockpit と multiyear で同じCockpitNavSidebarを共有できる。google.script.runの Web App context での動作確認: コックピット(?view=cockpit)は GAS Web App として配信されるためgoogle.script.runが使用可能。ただしHtmlService.createHtmlOutputFromFile()ではなくHtmlService.createTemplateFromFile().evaluate()経由の場合も確認。300_ui/302_spa_bridge.js:1のdoGet(e)実装を読んで確認すること。cockpit-wrapの現在の CSS 幅制約確認:cockpit.css:13の.cockpit-wrapにmax-widthやwidthが設定されている場合、左サイドバー追加後に崩れる可能性がある。cockpit-layoutでラップ後の余剰幅計算を事前に行う。conditionフィールドの全パターン確認:MENU_DEFINITION内の全item.condition値を grep で確認('isDev'/'hasTestRunner'以外に存在しないか)。000_infra/002_constants.js:287-431を全件スキャンする。
実装時の必須要件
menuDefinitionがnullの場合のフォールバック: SPA が無効化されている(N56_ENABLE_SPA=falseかつ非 dev 環境)場合menuDefinitionはnullになる。この場合CockpitNavSidebarは「メニューを読み込めません」と表示するか、そもそも非表示にする(menuDefinition === nullの場合はサイドバー自体をレンダリングしない)。二重実行防止の徹底:
runningFunc !== nullの間は全てのcockpit-nav-itemをdisabledにする(一つ実行中に別のボタンを押せないようにする)。GAS 関数は実行時間が 1〜30 秒程度かかる場合がある。separator: trueの正しいハンドリング:MenuCategory.itemsの各要素にseparator: trueがある場合、ボタンではなく<hr className="cockpit-nav-sep" />を挿入する。funcNameがundefinedの場合もクリックハンドラを付与しない。itemsネスト(サブメニュー)の対応方針:MENU_DEFINITIONのitem.items(sub-menu) は Phase 1 では実装しない。現状のMENU_DEFINITIONにitemsを持つエントリが存在しないことを事前調査 (上記 5) で確認してから実装開始する。localStorageキーの名前空間:cockpit_nav_open/cockpit_nav_catsのようにcockpit_nav_プレフィックスを付けて既存のf57_cockpit_*/f67_*キーとの衝突を避ける。cockpit_nav_catsの値は JSON 配列["カテゴリ名1", ...]で閉じているカテゴリを記録する(開いているカテゴリのリストではなく、閉じているカテゴリのリストにする方が「デフォルト全開」を自然に実現できる)。アニメーション中のチラつき防止:
is-closedクラスの切替は CSS transition (width 0.2s ease) で行う。ただしoverflow-x: hiddenを忘れると transition 中にテキストが一瞬はみ出す。white-space: nowrapをアイテムに付けてはみ出し防止する。モバイル / 狭い画面対応: コックピットは現状デスクトップ専用として設計されているが(PRD §NFR 参照)、サイドバー追加後も
cockpit-wrapの最小幅(min-width: 800px程度)を CSS で確保する。サイドバーが開いた状態で総幅が 1040px を超える場合はスクロール許容とする。
エッジケース・異常系
| # | 条件 | 検知方法 | 期待される挙動 | ログ |
|---|---|---|---|---|
| 1 | menuDefinition が null(SPA 無効化状態) | menuDefinition === null チェック | サイドバーを非表示(CockpitNavSidebar を条件レンダリング非表示) | なし(正常系) |
| 2 | menuDefinition は非 null だがカテゴリが空配列 | menuDefinition.length === 0 | サイドバーを表示するが「メニューがありません」と表示 | console.warn('[MAS-358] menuDefinition が空配列') |
| 3 | GAS 関数呼び出し中に別のボタンをクリック | runningFunc !== null | クリックを無視(全ボタン disabled) | なし |
| 4 | GAS 関数が withFailureHandler で失敗 | withFailureHandler コールバック | エラーメッセージを cockpit-nav-result.fail に 10 秒表示 | console.error('[MAS-358]', funcName, e.message) |
| 5 | GAS 関数が 30 秒以上応答なし(タイムアウト) | GAS Web App のタイムアウトは withFailureHandler に到達 | エラーメッセージ表示(タイムアウト判定は GAS 側が行う) | GAS 側: Utils.persistLog('ERROR', ...) |
| 6 | item.condition === 'isDev' の項目を prod 環境で表示 | isDev === false | 項目を非表示 | なし |
| 7 | item.condition === 'hasTestRunner' で prod デプロイ時(テストコード削除済み) | hasTestRunner === false | 項目を非表示(900_test/ は prod デプロイ時に GAS Actions が削除するため) | なし |
| 8 | localStorage が利用不可(プライベートブラウジング等) | try/catch で localStorage.setItem | 開閉状態を保存せず、デフォルト値(開いた状態)で毎回初期化 | console.warn('[MAS-358] localStorage 利用不可') |
| 9 | カテゴリ名に絵文字が含まれ正規表現マッチ失敗 | 変換後ラベルが空文字 | 元のカテゴリ名をそのまま表示 | なし |
| 10 | funcName が undefined(separator 行など) | typeof item.funcName === 'undefined' | <hr> を表示しボタンを生成しない | なし |
| 11 | Phase 2 で createMenu 削除後にスプレッドシートを再オープン | onOpen 関数が空になる | メニューバーに BizLP 等が表示されなくなる(想定動作) | Utils.persistLog('INFO', 'onOpen', 'スプレッドシートメニュー廃止済') |
| 12 | google.script.run が Web App context で未定義(Firebase Hosting 移行後) | typeof google === 'undefined' | console.warn のみ表示しボタンを disabled にする(MAS-357 移行後の将来対応) | console.warn('[MAS-358] google.script.run 利用不可 (非 GAS 環境)') |
ファイル変更マトリクス
| ファイル | 変更種別 | 変更内容 | 注意事項 |
|---|---|---|---|
webapp_client/src/cockpit/CockpitNavSidebar.tsx | 新規 | 左サイドバーコンポーネント全体 | google.script.run の型定義は @ts-expect-error で回避 |
webapp_client/src/CockpitApp.tsx | 変更 | cockpit-layout ラッパー追加 + menuDefinition / isDev / hasTestRunner の state 保持 | useMemo の依存配列に影響しないよう変更 |
webapp_client/src/MultiyearApp.tsx | 変更 | cockpit-layout ラッパー追加(CockpitNavSidebar 共有) | multiyear_spa ビューでも同一サイドバーを表示 |
webapp_client/src/styles/cockpit.css | 変更 | .cockpit-layout / .cockpit-nav-sidebar / .cockpit-nav-* クラス追加 | 既存 .cockpit-wrap の width 制約を確認してから追加 |
300_ui/302_spa_bridge.js | 変更なし | menuDefinition: Constants.MENU_DEFINITION は既に実装済(L196) | 確認のみ |
docs/spec/sidebar_api.d.ts | 変更なし | MenuCategory / MenuItem 型は既に定義済(L39-52) | 確認のみ |
100_config/101_sys_config.js | Phase 2 で変更 | MENU_DEFINITION.forEach の createMenu ブロック削除 | Phase 1 完了・動作確認後に実施 |
推奨実行モデル
| ステップ | 推奨モデル | 理由 |
|---|---|---|
Phase 1 コンポーネント実装 (CockpitNavSidebar.tsx) | Sonnet | UI コンポーネント実装・既存パターン適用(ScenarioBar.tsx 等を参考に) |
Phase 1 レイアウト変更 (CockpitApp.tsx / MultiyearApp.tsx) | Haiku | 機械的なラッパー追加・判断余地なし |
Phase 1 CSS 追加 (cockpit.css) | Haiku | CSS 追記・デザイントークンなし |
Phase 2 GAS メニュー削除 (101_sys_config.js) | Haiku | 特定行の削除・判断余地なし |
受け入れ条件
- コックピット (
?view=cockpit) を開いたとき左端にナビゲーションサイドバーが表示される - ☰ ボタンで開閉でき、状態が
localStorageに保存される(リロード後も維持) - 各カテゴリがアコーディオンで展開/折りたたみできる
- アイテムをクリックすると対応する GAS 関数が実行され、成功/失敗のフィードバックが表示される
-
isDev=falseの環境ではcondition: 'isDev'の項目が非表示になる -
hasTestRunner=falseの環境ではcondition: 'hasTestRunner'の項目が非表示になる - マルチイヤー (
?view=multiyear_spa) でも同じサイドバーが表示される - Phase 2 完了後、Google Sheets のメニューバーに
🚀 BizLP等が表示されなくなる - 既存コックピット機能(各パネルの計算・表示)に影響がない
依存関係・前提条件
- MAS-232 完了済: Vite+React SPA 基盤 (
302_spa_bridge.js/CockpitApp.tsx) が稼働中 - MAS-057 Phase 3 完了済: コックピット Web App として公開済
getInitialStateForSpaのmenuDefinition配信:302_spa_bridge.js:196で確認済- MAS-356 との関係(推奨先行): MAS-356 で Tremor (
@tremor/react) を導入済みであれば、CockpitNavSidebar.tsxの実装に Tremor のSidebar/SidebarItemコンポーネントを活用でき、本仕様書の CSS スクラッチ実装(1-D 節)が不要になる。MAS-356 未着手の場合はカスタム CSS で実装する(仕様書 1-D 節がそのまま適用)。いずれの場合も実装可能だが、MAS-356 後に実施すると工数が 1/3 程度に減る見込み。 - MAS-357 との関係(後続): Phase 2 の GAS メニュー廃止(
createMenu削除)は MAS-357 Firebase Hosting 移行より前に完了しておくことが望ましい。GAS ネイティブメニューは Firebase 環境で機能しないため、メニュー廃止を先行させることで移行時の切り戻しリスクを下げられる。Phase 1 のサイドバーは MAS-357 移行後もgoogle.script.runの代わりに REST API 呼び出しに切り替えることで維持可能(エッジケース #12 参照)。
変更履歴
| 日時 | バージョン | 変更内容 |
|---|---|---|
| 2026-05-03 | v1.0 | 初版起票 |
| 2026-05-03 | v1.1 | 推奨実装順を MAS-356 → MAS-358 → MAS-357 に明記・依存関係節を拡充 |