概要

項目内容
案件 IDMAS-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.jscreateMenu 呼び出し削除)
実装ステータス未着手

課題と目的

現状の問題

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.jscreateMenu 呼び出しを削除してスプレッドシートメニューを廃止

実装仕様

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 関数呼び出しコールバック
}

内部状態:

状態保存先初期値
isOpenbooleanlocalStorage['cockpit_nav_open']true
openCategoriesSet<string>localStorage['cockpit_nav_cats'] (JSON)全カテゴリ
runningFuncstring | nullstate のみnull
runResult{ok: boolean, msg: string} | nullstate のみnull

表示仕様:

  • 開いた状態 (幅 240px): ハンバーガーボタン(☰)+ カテゴリ見出し + アイテムリスト
  • 閉じた状態 (幅 48px): ハンバーガーボタン(☰)のみ(カテゴリは非表示)
    • ホバー時: CSS title 属性でツールチップ表示
  • カテゴリ: アコーディオン形式(クリックで展開/折りたたみ)
  • アイテム: ラベル(item.label)+ description(item.description、開いた状態でのみ表示)
  • セパレータ: item.separator === true の場合 <hr> を表示

条件フィルタリング:

MENU_DEFINITION の条件フィールドフィルタリングロジック
category.privileged === truePhase 1 では表示(簡易化)。Phase 2 で isPrivileged フラグを bootstrap に追加して非特権ユーザーに非表示
item.condition === 'isDev'isDevfalse の場合は非表示
item.condition === 'hasTestRunner'hasTestRunnerfalse の場合は非表示
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.tsxgetInitialStateForSpa の結果から cockpitBootstrap のみを利用しており、menuDefinition を破棄している。state オブジェクトを保持して menuDefinitionCockpitNavSidebar に渡す。

// 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.jscreateMenu 呼び出し削除

100_config/101_sys_config.jsonOpen / setupMenu_ 関数で Constants.MENU_DEFINITION.forEach(function(catDef) { ... menu.addToUi(); }) しているブロックを削除する。

削除対象: 101_sys_config.js:325-347Constants.MENU_DEFINITION.forEach ループ全体。

保持するもの: onOpen 関数自体(将来的に必要になる場合があるため)、ただし中身は空にする。

2-B. 002_constants.jsMENU_DEFINITION 保持方針

MENU_DEFINITION は引き続き 302_spa_bridge.jsgetInitialStateForSpa が参照するため 削除しないMENU_DEFINITION はコックピット左サイドバーのデータソースとして機能し続ける。

ただし category.source: 'sidebar' フィールドは現状のコックピット向けフィルタで使用しないため、将来的に source: 'cockpit_nav' に変更して使い分けることも検討余地あり(Phase 2 時点では変更不要)。

アーキテクト指示事項

Phase 1 実装前の必須調査

  1. CockpitApp.tsx:76callApi('getInitialStateForSpa', 'cockpit') 戻り値確認: state.menuDefinitionnull でないことを dev 環境で実機確認。302_spa_bridge.js:142-147 の条件分岐(Constants.MENU_DEFINITION が undefined の場合は null を返す)が通っていないことを grep で確認。

  2. MultiyearApp.tsxmenuDefinition 取得状況確認: callApi('getInitialStateForSpa', 'multiyear')menuDefinition を返しているか 302_spa_bridge.js:196 を参照して確認。cockpit と multiyear で同じ CockpitNavSidebar を共有できる。

  3. 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:1doGet(e) 実装を読んで確認すること。

  4. cockpit-wrap の現在の CSS 幅制約確認: cockpit.css:13.cockpit-wrapmax-widthwidth が設定されている場合、左サイドバー追加後に崩れる可能性がある。cockpit-layout でラップ後の余剰幅計算を事前に行う。

  5. condition フィールドの全パターン確認: MENU_DEFINITION 内の全 item.condition 値を grep で確認('isDev' / 'hasTestRunner' 以外に存在しないか)。000_infra/002_constants.js:287-431 を全件スキャンする。

実装時の必須要件

  1. menuDefinitionnull の場合のフォールバック: SPA が無効化されている(N56_ENABLE_SPA=false かつ非 dev 環境)場合 menuDefinitionnull になる。この場合 CockpitNavSidebar は「メニューを読み込めません」と表示するか、そもそも非表示にする(menuDefinition === null の場合はサイドバー自体をレンダリングしない)。

  2. 二重実行防止の徹底: runningFunc !== null の間は全ての cockpit-nav-itemdisabled にする(一つ実行中に別のボタンを押せないようにする)。GAS 関数は実行時間が 1〜30 秒程度かかる場合がある。

  3. separator: true の正しいハンドリング: MenuCategory.items の各要素に separator: true がある場合、ボタンではなく <hr className="cockpit-nav-sep" /> を挿入する。funcNameundefined の場合もクリックハンドラを付与しない。

  4. items ネスト(サブメニュー)の対応方針: MENU_DEFINITIONitem.items (sub-menu) は Phase 1 では実装しない。現状の MENU_DEFINITIONitems を持つエントリが存在しないことを事前調査 (上記 5) で確認してから実装開始する。

  5. localStorage キーの名前空間: cockpit_nav_open / cockpit_nav_cats のように cockpit_nav_ プレフィックスを付けて既存の f57_cockpit_* / f67_* キーとの衝突を避ける。cockpit_nav_cats の値は JSON 配列 ["カテゴリ名1", ...] で閉じているカテゴリを記録する(開いているカテゴリのリストではなく、閉じているカテゴリのリストにする方が「デフォルト全開」を自然に実現できる)。

  6. アニメーション中のチラつき防止: is-closed クラスの切替は CSS transition (width 0.2s ease) で行う。ただし overflow-x: hidden を忘れると transition 中にテキストが一瞬はみ出す。white-space: nowrap をアイテムに付けてはみ出し防止する。

  7. モバイル / 狭い画面対応: コックピットは現状デスクトップ専用として設計されているが(PRD §NFR 参照)、サイドバー追加後も cockpit-wrap の最小幅(min-width: 800px 程度)を CSS で確保する。サイドバーが開いた状態で総幅が 1040px を超える場合はスクロール許容とする。

エッジケース・異常系

#条件検知方法期待される挙動ログ
1menuDefinitionnull(SPA 無効化状態)menuDefinition === null チェックサイドバーを非表示(CockpitNavSidebar を条件レンダリング非表示)なし(正常系)
2menuDefinition は非 null だがカテゴリが空配列menuDefinition.length === 0サイドバーを表示するが「メニューがありません」と表示console.warn('[MAS-358] menuDefinition が空配列')
3GAS 関数呼び出し中に別のボタンをクリックrunningFunc !== nullクリックを無視(全ボタン disabled)なし
4GAS 関数が withFailureHandler で失敗withFailureHandler コールバックエラーメッセージを cockpit-nav-result.fail に 10 秒表示console.error('[MAS-358]', funcName, e.message)
5GAS 関数が 30 秒以上応答なし(タイムアウト)GAS Web App のタイムアウトは withFailureHandler に到達エラーメッセージ表示(タイムアウト判定は GAS 側が行う)GAS 側: Utils.persistLog('ERROR', ...)
6item.condition === 'isDev' の項目を prod 環境で表示isDev === false項目を非表示なし
7item.condition === 'hasTestRunner' で prod デプロイ時(テストコード削除済み)hasTestRunner === false項目を非表示(900_test/ は prod デプロイ時に GAS Actions が削除するため)なし
8localStorage が利用不可(プライベートブラウジング等)try/catchlocalStorage.setItem開閉状態を保存せず、デフォルト値(開いた状態)で毎回初期化console.warn('[MAS-358] localStorage 利用不可')
9カテゴリ名に絵文字が含まれ正規表現マッチ失敗変換後ラベルが空文字元のカテゴリ名をそのまま表示なし
10funcNameundefinedseparator 行など)typeof item.funcName === 'undefined'<hr> を表示しボタンを生成しないなし
11Phase 2 で createMenu 削除後にスプレッドシートを再オープンonOpen 関数が空になるメニューバーに BizLP 等が表示されなくなる(想定動作)Utils.persistLog('INFO', 'onOpen', 'スプレッドシートメニュー廃止済')
12google.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-wrapwidth 制約を確認してから追加
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.jsPhase 2 で変更MENU_DEFINITION.forEachcreateMenu ブロック削除Phase 1 完了・動作確認後に実施

推奨実行モデル

ステップ推奨モデル理由
Phase 1 コンポーネント実装 (CockpitNavSidebar.tsx)SonnetUI コンポーネント実装・既存パターン適用(ScenarioBar.tsx 等を参考に)
Phase 1 レイアウト変更 (CockpitApp.tsx / MultiyearApp.tsx)Haiku機械的なラッパー追加・判断余地なし
Phase 1 CSS 追加 (cockpit.css)HaikuCSS 追記・デザイントークンなし
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 として公開済
  • getInitialStateForSpamenuDefinition 配信: 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-03v1.0初版起票
2026-05-03v1.1推奨実装順を MAS-356 → MAS-358 → MAS-357 に明記・依存関係節を拡充