1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google Apps Script + スプレッドシートで作る、インフラエンジニア向けキャリアパス管理ツール

1
Last updated at Posted at 2026-03-26

はじめに

エンジニアのキャリアの振り返りやキャリアパスを考える際に、Excelやスプレッドシートにとりあえず経歴をまとめたりして運用しているチームは多いと思います。ただ、「社内で手軽に編集・参照できて、かつモダンな見た目にしたい」「一覧性を高めたい」となると、意外と選択肢が限られてきます。

今回、GAS + スプレッドシートでそれらの要件を満たすような簡単なWebアプリを作成してみました。
(作成に当たってClaudeを存分に活用しました)

  • Web画面でぽちぽち編集できる
  • スプレッドシートにデータを永続保存
  • 社内のGoogleアカウントで認証管理できる
  • デプロイURLを変えずに中身を更新できる
  • 追加費用ゼロ

完成イメージ

スキル領域 × 工程フェーズ のマトリクスで、各セルに習熟レベルと期待スキルを定義できます。
image.png

インフラエンジニア向けの領域として以下を定義しました。

領域 内容
OS Windows / Linux
NW Router / FW / LB
Cloud AWS / GCP / Azure
Container Docker / Kubernetes
MW Apache / Tomcat / Backup
Hypervisor VMware / Hyper-V / KVM
HW 物理機器 / DC作業

工程フェーズは 要件定義 → 基本設計 → 詳細設計 → 構築 → 単体テスト → 結合テスト → 総合テスト → 運用保守 の8段階です。


なぜGASを選んだか

候補として以下を検討しました。

  • Google Sites → HTMLを直接編集できないため断念
  • GitHub Pages / Netlify → 社内利用にはGoogleアカウント認証がない
  • 社内サーバー → 構築・維持コストがかかる

GASは SpreadsheetApp でスプレッドシートと直接連携でき、HtmlService でそのままWebアプリとして公開できます。社内のGoogleワークスペース環境であれば、アクセス制限もGoogleアカウントで管理できるため、今回の用途に最もフィットしていました。


アーキテクチャ

ブラウザ(HTML + CSS + JS)
    ↕ google.script.run(非同期RPC)
コード.gs(サーバーサイド)
    ↕ SpreadsheetApp
Googleスプレッドシート(データ永続化)

シンプルに言うと、HTMLが画面を担当し、GASがAPIとデータストアの橋渡しをする 構成です。


セットアップ手順

1. スプレッドシートを作成

Googleドライブで新しいスプレッドシートを作成します。シート名は任意でOKです。

スプレッドシートのURLからIDをコピーしておきます。

https://docs.google.com/spreadsheets/d/【ここがID】/edit

2. Apps Scriptを作成

スプレッドシートを開き、「拡張機能」→「Apps Script」を開きます。

左サイドバーの「ファイル」から以下の2ファイルを作成します。

コード.gs    ← サーバーサイド(最初からあるファイルを使用)
index        ← HTMLファイル(.htmlなしの名前で作成)

3. コード.gs を実装

「メンバーIDとシート名を1対1で紐付けて、スプシへの読み書きをラップするAPI群」を実装します。HTMLからは google.script.run 経由でこれらを呼ぶだけなので、画面側はデータの保存先を意識しなくてよい設計になっています。

コード全文を見る
const SPREADSHEET_ID = ''; // ← スプレッドシートのIDをここに入れる

function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('Infra Career Path')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// メンバーIDからシート名を生成
function getSheetName(memberId) {
  return 'CareerData_' + memberId;
}

// メンバー一覧を取得(シート名から自動収集)
function getMembers() {
  const ss     = SpreadsheetApp.openById(SPREADSHEET_ID);
  const sheets = ss.getSheets();
  return sheets
    .map(function(s) { return s.getName(); })
    .filter(function(name) { return name.indexOf('CareerData_') === 0; })
    .map(function(name) { return name.replace('CareerData_', ''); });
}

// データ取得
function getData(memberId) {
  try {
    const ss        = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheetName = getSheetName(memberId);
    const sheet     = ss.getSheetByName(sheetName);
    if (!sheet || sheet.getLastRow() < 2) return { memberName: memberId, cells: {} };

    const memberName = sheet.getRange(1, 5).getValue() || memberId;
    const lastRow    = sheet.getLastRow();
    const rows       = sheet.getRange(2, 1, lastRow - 1, 4).getValues();

    const cells = {};
    rows.forEach(function(row) {
      const skillId  = row[0];
      const phaseId  = row[1];
      const level    = row[2];
      const itemsRaw = row[3];
      if (!skillId || !phaseId) return;
      if (!cells[skillId]) cells[skillId] = {};
      cells[skillId][phaseId] = {
        level: level || 'none',
        items: itemsRaw ? JSON.parse(itemsRaw) : []
      };
    });
    return { memberName: memberName, cells: cells };
  } catch(e) {
    console.log('getData error: ' + e);
    return { memberName: memberId, cells: {} };
  }
}

// データ保存
function saveData(memberId, jsonStr) {
  try {
    const ss        = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheetName = getSheetName(memberId);
    let sheet       = ss.getSheetByName(sheetName);

    // シートがなければ新規作成
    if (!sheet) sheet = ss.insertSheet(sheetName);

    const payload    = JSON.parse(jsonStr);
    const memberName = payload.memberName || memberId;
    const data       = payload.cells;

    sheet.clearContents();
    sheet.getRange(1, 1, 1, 4).setValues([['skillId', 'phaseId', 'level', 'items']]);
    sheet.getRange(1, 5).setValue(memberName);

    const rows = [];
    Object.keys(data).forEach(function(skillId) {
      Object.keys(data[skillId]).forEach(function(phaseId) {
        const cell = data[skillId][phaseId];
        rows.push([
          skillId,
          phaseId,
          cell.level || 'none',
          JSON.stringify(cell.items || [])
        ]);
      });
    });

    if (rows.length > 0) {
      sheet.getRange(2, 1, rows.length, 4).setValues(rows);
    }
    return 'ok';
  } catch(e) {
    console.log('saveData error: ' + e);
    return 'error: ' + e;
  }
}

// メンバー追加(新規シート作成)
function addMember(memberId) {
  try {
    const ss        = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheetName = getSheetName(memberId);
    if (ss.getSheetByName(sheetName)) return 'exists'; // すでにある
    ss.insertSheet(sheetName);
    return 'ok';
  } catch(e) {
    return 'error: ' + e;
  }
}

// メンバー削除(シートごと削除)
function deleteMember(memberId) {
  try {
    const ss        = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheetName = getSheetName(memberId);
    const sheet     = ss.getSheetByName(sheetName);
    if (!sheet) return 'notfound';
    ss.deleteSheet(sheet);
    return 'ok';
  } catch(e) {
    return 'error: ' + e;
  }
}

ポイントはスキル領域をハードコードしていない点です。Object.keys() で動的に処理するため、HTMLの定義にスキルや工程を追加してもGAS側の変更は不要です。

4. スプレッドシートのデータ構造

保存後のスプレッドシートはこのようになります。

A(skillId) B(phaseId) C(level) D(items) E(メンバー名)
os req mid ["要件ヒアリング..."] 山田太郎
os bdes senior ["サーバー台数..."]
nw req basic ["帯域・レイテンシ..."]

items 列はJSON配列の文字列として保存しています。後述のGemini直貼り運用ではこの形式に合わせる必要があります。

5. index.html を実装

各セルに最低限の記載例入りのindex.htmlを用意しました。そのままコピペして活用してください。

コード全文を見る
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Infra Engineer Career Path</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Zen+Kaku+Gothic+New:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
  --bg:      #06080f;
  --s1:      #0b0f1a;
  --s2:      #111827;
  --s3:      #1a2235;
  --border:  rgba(148,163,184,0.08);
  --border2: rgba(148,163,184,0.14);
  --text:    #e2e8f0;
  --muted:   #64748b;
  --dim:     #374151;
  --ph0: #818cf8;
  --ph1: #38bdf8;
  --ph2: #34d399;
  --ph3: #fbbf24;
  --ph4: #f87171;
  --ph5: #e879f9;
  --ph6: #fb923c;
  --ph7: #34d399;
  --os:    #60a5fa;
  --nw:    #4ade80;
  --cloud: #fb923c;
  --ctnr:  #a78bfa;
  --mw:    #f472b6;
  --hv:    #2dd4bf;
  --hw:    #facc15;
  --danger: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: 'Zen Kaku Gothic New', sans-serif; min-height: 100vh; }

/* ── MEMBER SWITCHER ── */
.member-switcher {
  background: var(--s1);
  border-bottom: 1px solid var(--border);
  padding: 10px 40px;
  display: flex; align-items: center; gap: 10px;
  overflow-x: auto;
}
.switcher-label {
  font-family: 'DM Mono', monospace; font-size: 9px;
  color: var(--muted); letter-spacing: 1.5px; text-transform: uppercase;
  white-space: nowrap; flex-shrink: 0;
}
.member-tab {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 14px; border-radius: 6px;
  font-size: 12px; font-weight: 500;
  font-family: 'DM Mono', monospace;
  background: var(--s2); color: var(--muted);
  border: 1px solid var(--border2);
  cursor: pointer; transition: all .15s; white-space: nowrap;
  flex-shrink: 0;
}
.member-tab:hover { color: var(--text); border-color: rgba(96,165,250,.3); }
.member-tab.active { background: rgba(96,165,250,.1); color: var(--os); border-color: rgba(96,165,250,.3); }
.member-tab-del {
  font-size: 13px; opacity: .4; line-height: 1;
  display: none;
}
body.edit-mode .member-tab-del { display: inline; }
.member-tab-del:hover { opacity: 1; color: var(--danger); }
.add-member-btn {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 5px 12px; border-radius: 6px;
  font-size: 11px; font-weight: 500;
  font-family: 'DM Mono', monospace;
  background: transparent; color: var(--dim);
  border: 1px dashed var(--dim);
  cursor: pointer; transition: all .15s; white-space: nowrap;
  flex-shrink: 0; display: none;
}
body.edit-mode .add-member-btn { display: inline-flex; }
.add-member-btn:hover { color: var(--os); border-color: var(--os); }
body::before {
  content: ''; position: fixed; inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
  pointer-events: none; z-index: 0;
}
.wrap { position: relative; z-index: 1; }

/* ── HEADER ── */
.header { padding: 36px 40px 28px; border-bottom: 1px solid var(--border); display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }
.header-left { display: flex; align-items: flex-start; gap: 16px; }
.icon-box { width: 44px; height: 44px; border-radius: 10px; margin-top: 4px; background: linear-gradient(135deg, #1e3a5f, #0f2040); border: 1px solid rgba(96,165,250,0.3); display: flex; align-items: center; justify-content: center; font-size: 20px; box-shadow: 0 0 16px rgba(96,165,250,0.1); flex-shrink: 0; }
.header h1 { font-family: 'DM Mono', monospace; font-size: 20px; font-weight: 500; letter-spacing: -0.3px; color: var(--text); }
.header h1 span { color: var(--os); font-size: 13px; display: block; font-weight: 400; margin-top: 2px; letter-spacing: 1px; text-transform: uppercase; }

/* ── MEMBER NAME ── */
.member-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.member-label { font-family: 'DM Mono', monospace; font-size: 9px; color: var(--muted); letter-spacing: 1.5px; text-transform: uppercase; flex-shrink: 0; }
.member-name { font-family: 'DM Mono', monospace; font-size: 16px; font-weight: 500; color: var(--text); border-bottom: 1px dashed var(--border2); padding-bottom: 2px; transition: all .15s; min-width: 80px; letter-spacing: 0.3px; }
body.edit-mode .member-name { cursor: pointer; color: var(--os); border-bottom-color: rgba(96,165,250,0.4); }
body.edit-mode .member-name:hover { border-bottom-style: solid; }
.member-edit-hint { font-size: 10px; color: var(--dim); opacity: 0; transition: opacity .2s; pointer-events: none; }
body.edit-mode .member-edit-hint { opacity: 1; }
.member-input { font-family: 'DM Mono', monospace; font-size: 16px; font-weight: 500; background: transparent; border: none; border-bottom: 1px solid var(--os); color: var(--os); outline: none; padding-bottom: 2px; min-width: 160px; letter-spacing: 0.3px; }

.header-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 500; letter-spacing: 0.4px; font-family: 'DM Mono', monospace; }
.badge-admin { background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.2); color: #fbbf24; }
.badge-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: blink 2s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 12px; font-weight: 500; font-family: 'Zen Kaku Gothic New', sans-serif; cursor: pointer; transition: all .2s; border: none; letter-spacing: 0.3px; }
.btn-edit  { background: var(--s2); color: var(--muted); border: 1px solid var(--border2); }
.btn-edit:hover  { color: var(--text); border-color: rgba(96,165,250,0.3); }
.btn-edit.active { background: rgba(96,165,250,0.1); color: var(--os); border-color: rgba(96,165,250,0.3); }
.btn-save   { background: var(--os); color: #060c18; }
.btn-save:hover  { filter: brightness(1.1); box-shadow: 0 0 16px rgba(96,165,250,0.3); }
.btn-danger { background: rgba(248,113,113,0.08); color: var(--danger); border: 1px solid rgba(248,113,113,0.25); display: none; }
.btn-danger:hover { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.5); }
body.edit-mode .btn-danger { display: inline-flex; }

/* ── LEGEND ── */
.legend-bar { padding: 14px 40px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; overflow-x: auto; }
.legend-label { font-size: 10px; color: var(--muted); font-family: 'DM Mono', monospace; letter-spacing: 1px; text-transform: uppercase; white-space: nowrap; margin-right: 4px; }
.legend-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 4px; font-size: 10px; font-weight: 500; font-family: 'DM Mono', monospace; white-space: nowrap; letter-spacing: 0.3px; }

/* ── GRID ── */
.grid-outer { padding: 32px 40px 60px; overflow-x: auto; }
.grid-wrap { min-width: 960px; }
.phase-header-row { display: grid; grid-template-columns: 160px repeat(8, 1fr); gap: 6px; margin-bottom: 6px; }
.phase-header-cell { padding: 10px 6px; text-align: center; }
.phase-chip { display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 8px 4px; border-radius: 8px; border-width: 1px; border-style: solid; }
.phase-chip .ph-num { font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500; letter-spacing: 1px; opacity: .6; }
.phase-chip .ph-name { font-size: 12px; font-weight: 700; letter-spacing: -.2px; }
.skill-row { display: grid; grid-template-columns: 160px repeat(8, 1fr); gap: 6px; margin-bottom: 6px; }
.skill-label { display: flex; align-items: center; gap: 10px; padding: 0 8px; }
.skill-indicator { width: 3px; border-radius: 2px; align-self: stretch; min-height: 100px; flex-shrink: 0; }
.skill-meta { display: flex; flex-direction: column; gap: 2px; }
.skill-name { font-size: 13px; font-weight: 700; }
.skill-sub { font-size: 10px; color: var(--muted); font-family: 'DM Mono', monospace; letter-spacing: .3px; }
.cell { background: var(--s1); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; min-height: 120px; position: relative; transition: all .2s; cursor: default; overflow: hidden; }
.cell::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; opacity: 0; transition: opacity .2s; border-radius: 0 0 10px 10px; }
.cell:hover { background: var(--s2); border-color: var(--border2); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.4); }
.cell:hover::after { opacity: 1; }
.cell-level { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; margin-bottom: 8px; font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
.cell-items { display: flex; flex-direction: column; gap: 5px; }
.cell-item { display: flex; align-items: flex-start; gap: 6px; font-size: 11px; color: var(--text); line-height: 1.5; }
.cell-item-dot { width: 4px; height: 4px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.cell-empty { display: flex; align-items: center; justify-content: center; height: 100%; min-height: 100px; color: var(--dim); font-size: 18px; }
.cell-edit-btn { position: absolute; top: 8px; right: 8px; width: 24px; height: 24px; border-radius: 5px; background: var(--s3); border: 1px solid var(--border2); color: var(--muted); cursor: pointer; display: none; align-items: center; justify-content: center; font-size: 11px; transition: all .15s; }
.cell:hover .cell-edit-btn, body.edit-mode .cell-edit-btn { display: flex; }
.cell-edit-btn:hover { color: var(--os); border-color: rgba(96,165,250,.4); }
body.edit-mode .cell { cursor: pointer; border-style: dashed; }
body.edit-mode .cell:hover { border-color: var(--os); border-style: solid; }
.cell[data-ph="0"]::after { background: var(--ph0); }
.cell[data-ph="1"]::after { background: var(--ph1); }
.cell[data-ph="2"]::after { background: var(--ph2); }
.cell[data-ph="3"]::after { background: var(--ph3); }
.cell[data-ph="4"]::after { background: var(--ph4); }
.cell[data-ph="5"]::after { background: var(--ph5); }
.cell[data-ph="6"]::after { background: var(--ph6); }
.cell[data-ph="7"]::after { background: var(--ph7); }

/* ── CELL MODAL ── */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.75); backdrop-filter: blur(6px); z-index: 100; display: none; align-items: center; justify-content: center; }
.overlay.open { display: flex; animation: fadein .2s ease; }
@keyframes fadein { from{opacity:0} to{opacity:1} }
.modal { background: var(--s1); border: 1px solid var(--border2); border-radius: 18px; width: 520px; max-width: 92vw; max-height: 88vh; overflow-y: auto; padding: 28px 32px; box-shadow: 0 32px 80px rgba(0,0,0,.7); animation: slideup .25s ease; }
@keyframes slideup { from{opacity:0;transform:translateY(16px)} to{opacity:1;transform:none} }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.modal-head h2 { font-size: 17px; font-weight: 700; line-height: 1.3; }
.modal-head small { display: block; font-size: 11px; color: var(--muted); margin-top: 3px; font-family:'DM Mono',monospace; }
.close-btn { width: 30px; height: 30px; border-radius: 7px; border: 1px solid var(--border2); background: transparent; color: var(--muted); cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all .15s; }
.close-btn:hover { color: var(--text); background: var(--s2); }
.form-group { margin-bottom: 18px; }
.form-label { display: block; font-size: 11px; font-weight: 500; color: var(--muted); margin-bottom: 7px; font-family: 'DM Mono', monospace; letter-spacing: .5px; text-transform: uppercase; }
.form-help { font-size: 10px; color: var(--dim); margin-top: 5px; }
.bullet-editor { background: var(--bg); border: 1px solid var(--border2); border-radius: 10px; overflow: hidden; transition: border-color .2s; }
.bullet-editor:focus-within { border-color: rgba(96,165,250,.35); box-shadow: 0 0 0 3px rgba(96,165,250,.06); }
.bullet-row { display: flex; align-items: center; border-bottom: 1px solid var(--border); }
.bullet-row:last-child { border-bottom: none; }
.bullet-num { width: 36px; text-align: center; font-family: 'DM Mono', monospace; font-size: 10px; color: var(--dim); flex-shrink: 0; padding: 10px 0; border-right: 1px solid var(--border); user-select: none; }
.bullet-input { flex: 1; background: transparent; border: none; outline: none; padding: 10px 12px; color: var(--text); font-size: 12px; font-family: 'Zen Kaku Gothic New', sans-serif; line-height: 1.5; }
.bullet-del { width: 32px; height: 32px; background: transparent; border: none; color: var(--dim); cursor: pointer; font-size: 14px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: color .15s; }
.bullet-del:hover { color: #f87171; }
.add-bullet-btn { width: 100%; padding: 8px; background: transparent; border: none; border-top: 1px solid var(--border); color: var(--dim); font-size: 11px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all .15s; font-family: 'Zen Kaku Gothic New', sans-serif; }
.add-bullet-btn:hover { color: var(--os); background: rgba(96,165,250,.04); }
.level-select-row { display: flex; gap: 6px; flex-wrap: wrap; }
.level-opt { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border2); background: var(--bg); color: var(--muted); font-size: 11px; font-weight: 500; cursor: pointer; transition: all .15s; font-family: 'DM Mono', monospace; }
.level-opt.selected { border-color: rgba(96,165,250,.4); color: var(--os); background: rgba(96,165,250,.08); }
.level-opt:hover:not(.selected) { color: var(--text); }
.modal-footer { display: flex; gap: 8px; justify-content: flex-end; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }

/* ── CONFIRM DIALOG ── */
.confirm-overlay {
  position: fixed; inset: 0;
  background: rgba(0,0,0,.8);
  backdrop-filter: blur(8px);
  z-index: 200;
  display: none; align-items: center; justify-content: center;
}
.confirm-overlay.open { display: flex; animation: fadein .2s ease; }
.confirm-box {
  background: var(--s1);
  border: 1px solid rgba(248,113,113,0.3);
  border-radius: 18px;
  padding: 32px;
  width: 420px; max-width: 90vw;
  box-shadow: 0 32px 80px rgba(0,0,0,.7), 0 0 0 1px rgba(248,113,113,0.1);
  animation: slideup .25s ease;
  text-align: center;
}
.confirm-icon {
  font-size: 36px; margin-bottom: 16px; display: block;
}
.confirm-title {
  font-family: 'DM Mono', monospace;
  font-size: 16px; font-weight: 500;
  color: var(--danger); margin-bottom: 10px;
}
.confirm-desc {
  font-size: 13px; color: var(--muted); line-height: 1.7;
  margin-bottom: 28px;
}
.confirm-desc strong { color: var(--text); }
.confirm-actions { display: flex; gap: 10px; justify-content: center; }
.btn-confirm-yes {
  background: var(--danger); color: #fff;
  padding: 10px 28px; border-radius: 8px;
  font-size: 13px; font-weight: 600;
  font-family: 'Zen Kaku Gothic New', sans-serif;
  border: none; cursor: pointer; transition: all .2s;
}
.btn-confirm-yes:hover { filter: brightness(1.1); box-shadow: 0 0 16px rgba(248,113,113,0.3); }
.btn-confirm-no {
  background: var(--s2); color: var(--muted);
  padding: 10px 28px; border-radius: 8px;
  font-size: 13px; font-weight: 500;
  font-family: 'Zen Kaku Gothic New', sans-serif;
  border: 1px solid var(--border2); cursor: pointer; transition: all .2s;
}
.btn-confirm-no:hover { color: var(--text); border-color: var(--border2); }

/* ── TOAST ── */
.toast { position: fixed; bottom: 28px; right: 28px; background: var(--s2); border: 1px solid rgba(52,211,153,.25); color: #34d399; padding: 10px 18px; border-radius: 8px; font-size: 12px; font-weight: 500; z-index: 300; display: flex; align-items: center; gap: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.4); animation: toastIn .25s ease; font-family: 'DM Mono', monospace; }
.toast.danger { border-color: rgba(248,113,113,.3); color: var(--danger); }
@keyframes toastIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-thumb { background: rgba(96,165,250,.15); border-radius: 3px; }
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:none} }
</style>
</head>
<body>
<div class="wrap">

<!-- Member Switcher -->
<div class="member-switcher" id="memberSwitcher">
  <span class="switcher-label">MEMBER</span>
  <div id="memberTabs"></div>
  <button class="add-member-btn" onclick="promptAddMember()"> メンバー追加</button>
</div>

<!-- Header -->
<div class="header">
  <div class="header-left">
    <div class="icon-box">☁️</div>
    <div>
      <h1>Infra Engineer Career Path
        <span>スキル領域 × 工程フェーズ</span>
      </h1>
      <div class="member-row">
        <span class="member-label">MEMBER</span>
        <span class="member-name" id="memberName" onclick="editMemberName()"></span>
        <span class="member-edit-hint"> クリックして編集</span>
      </div>
    </div>
  </div>
  <div class="header-right">
    <div class="badge badge-admin"><span class="badge-dot"></span>管理者</div>
    <!-- 全クリアボタン編集モード時のみ表示 -->
    <button class="btn btn-danger" onclick="confirmClearAll()">🗑 全クリア</button>
    <button class="btn btn-edit" id="editBtn" onclick="toggleEdit()"> 編集モード</button>
  </div>
</div>

<!-- Legend -->
<div class="legend-bar">
  <span class="legend-label">フェーズ</span>
  <span class="legend-chip" style="background:rgba(129,140,248,.1);border:1px solid rgba(129,140,248,.25);color:var(--ph0)"> 要件定義</span>
  <span class="legend-chip" style="background:rgba(56,189,248,.1);border:1px solid rgba(56,189,248,.25);color:var(--ph1)"> 基本設計</span>
  <span class="legend-chip" style="background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.25);color:var(--ph2)"> 詳細設計</span>
  <span class="legend-chip" style="background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.25);color:var(--ph3)"> 構築</span>
  <span class="legend-chip" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.25);color:var(--ph4)"> 単体テスト</span>
  <span class="legend-chip" style="background:rgba(232,121,249,.1);border:1px solid rgba(232,121,249,.25);color:var(--ph5)"> 結合テスト</span>
  <span class="legend-chip" style="background:rgba(251,146,60,.1);border:1px solid rgba(251,146,60,.25);color:var(--ph6)"> 総合テスト</span>
  <span class="legend-chip" style="background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.25);color:var(--ph7)"> 運用保守</span>
  <span style="flex:1"></span>
  <span class="legend-label">領域</span>
  <span class="legend-chip" style="background:rgba(96,165,250,.08);border:1px solid rgba(96,165,250,.2);color:var(--os)">OS</span>
  <span class="legend-chip" style="background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.2);color:var(--nw)">NW</span>
  <span class="legend-chip" style="background:rgba(251,146,60,.08);border:1px solid rgba(251,146,60,.2);color:var(--cloud)">Cloud</span>
  <span class="legend-chip" style="background:rgba(167,139,250,.08);border:1px solid rgba(167,139,250,.2);color:var(--ctnr)">Container</span>
  <span class="legend-chip" style="background:rgba(244,114,182,.08);border:1px solid rgba(244,114,182,.2);color:var(--mw)">MW</span>
  <span class="legend-chip" style="background:rgba(45,212,191,.08);border:1px solid rgba(45,212,191,.2);color:var(--hv)">Hypervisor</span>
  <span class="legend-chip" style="background:rgba(250,204,21,.08);border:1px solid rgba(250,204,21,.2);color:var(--hw)">HW</span>
</div>

<!-- Grid -->
<div class="grid-outer">
  <div class="grid-wrap">
    <div class="phase-header-row" id="phaseHeader"></div>
    <div id="gridRows"></div>
  </div>
</div>

</div>

<!-- Cell Edit Modal -->
<div class="overlay" id="overlay">
  <div class="modal">
    <div class="modal-head">
      <div>
        <h2 id="modalTitle">セル編集</h2>
        <small id="modalSub"></small>
      </div>
      <button class="close-btn" onclick="closeModal()"></button>
    </div>
    <div class="form-group">
      <label class="form-label">習熟レベル</label>
      <div class="level-select-row" id="levelSelect"></div>
    </div>
    <div class="form-group">
      <label class="form-label">期待スキル経験 <span style="color:var(--dim);text-transform:none;letter-spacing:0"> 箇条書きで入力</span></label>
      <div class="bullet-editor" id="bulletEditor"></div>
      <button class="add-bullet-btn" onclick="addBullet()"> 項目を追加</button>
      <div class="form-help">Enterキーで次の行へ空行Backspaceで行削除</div>
    </div>
    <div class="modal-footer">
      <button class="btn btn-edit" onclick="closeModal()">キャンセル</button>
      <button class="btn btn-save" onclick="saveCell()">💾 保存</button>
    </div>
  </div>
</div>

<!-- Confirm Dialog -->
<div class="confirm-overlay" id="confirmOverlay">
  <div class="confirm-box">
    <span class="confirm-icon">⚠️</span>
    <div class="confirm-title">全データをクリアしますか</div>
    <div class="confirm-desc">
      すべてのセルの内容が削除されます<br>
      <strong>この操作は取り消せません</strong>
    </div>
    <div class="confirm-actions">
      <button class="btn-confirm-no"  onclick="closeConfirm()">キャンセル</button>
      <button class="btn-confirm-yes" onclick="execClearAll()">削除する</button>
    </div>
  </div>
</div>

<script>
// ── State ────────────────────────────────────────────────
var isEdit      = false;
var editing     = null;
var bullets     = [];
var data        = {};
var memberName  = '';
var currentMemberId = '';   // 現在表示中のメンバーID
var memberList  = [];       // メンバーID一覧

// ── Config ───────────────────────────────────────────────
var PHASES = [
  { id:'req',   name:'要件定義',   color:'var(--ph0)', bg:'rgba(129,140,248,.08)', border:'rgba(129,140,248,.2)' },
  { id:'bdes',  name:'基本設計',   color:'var(--ph1)', bg:'rgba(56,189,248,.08)',  border:'rgba(56,189,248,.2)' },
  { id:'ddes',  name:'詳細設計',   color:'var(--ph2)', bg:'rgba(52,211,153,.08)',  border:'rgba(52,211,153,.2)' },
  { id:'build', name:'構築',       color:'var(--ph3)', bg:'rgba(251,191,36,.08)',  border:'rgba(251,191,36,.2)' },
  { id:'ut',    name:'単体テスト', color:'var(--ph4)', bg:'rgba(248,113,113,.08)', border:'rgba(248,113,113,.2)' },
  { id:'it',    name:'結合テスト', color:'var(--ph5)', bg:'rgba(232,121,249,.08)', border:'rgba(232,121,249,.2)' },
  { id:'st',    name:'総合テスト', color:'var(--ph6)', bg:'rgba(251,146,60,.08)',  border:'rgba(251,146,60,.2)' },
  { id:'op',    name:'運用保守',   color:'var(--ph7)', bg:'rgba(52,211,153,.08)',  border:'rgba(52,211,153,.2)' },
];
var SKILLS = [
  { id:'os',    name:'OS',        sub:'Windows / Linux',          color:'var(--os)',    dotColor:'#60a5fa' },
  { id:'nw',    name:'NW',        sub:'Router / FW / LB',         color:'var(--nw)',    dotColor:'#4ade80' },
  { id:'cloud', name:'Cloud',     sub:'AWS / GCP / Azure',        color:'var(--cloud)', dotColor:'#fb923c' },
  { id:'ctnr',  name:'Container', sub:'Docker / Kubernetes',      color:'var(--ctnr)',  dotColor:'#a78bfa' },
  { id:'mw',    name:'MW',        sub:'Apache / Tomcat / Backup',  color:'var(--mw)',    dotColor:'#f472b6' },
  { id:'hv',    name:'Hypervisor', sub:'VMware / Hyper-V / KVM',  color:'var(--hv)',    dotColor:'#2dd4bf' },
  { id:'hw',    name:'HW',        sub:'物理機器 / DC作業',          color:'var(--hw)',    dotColor:'#facc15' },
];
var LEVELS = [
  { id:'none',   label:'',      color:'#374151' },
  { id:'basic',  label:'基礎',   color:'#60a5fa' },
  { id:'mid',    label:'一人前', color:'#34d399' },
  { id:'senior', label:'上級',   color:'#fbbf24' },
  { id:'lead',   label:'リード', color:'#f87171' },
];
var DEFAULT = {
  os: {
    req:   { level:'mid',    items:['要件ヒアリングからOS選定(Windows Server / RHEL / Ubuntu)の提案','仮想化方式(VMware / Hyper-V)の要件整理','ライセンス・サポート期限の確認と提示'] },
    bdes:  { level:'mid',    items:['サーバー台数・スペック・冗長構成の基本設計','OS標準設定方針(ファイルシステム・スワップ・タイムゾーン)','バックアップ方式の基本設計'] },
    ddes:  { level:'senior', items:['パーティション設計・LVM構成の詳細設計','ユーザー・グループ・sudoers の詳細設計','起動スクリプト・systemd Unit の設計','ロールベースのOS強化(CIS Benchmark 適用方針)'] },
    build: { level:'senior', items:['Kickstart / Ansible による自動構築','OSチューニング(kernel パラメータ・ulimit)','AD / LDAP 参加・認証連携','ウイルス対策・ログ収集エージェント導入'] },
    ut:    { level:'mid',    items:['OS起動・停止の確認','設定値の突合テスト(パラメータシート vs 実機)','冗長切替テスト(フェイルオーバー確認)'] },
    it:    { level:'mid',    items:['APサーバーとの疎通確認','監視エージェントからのアラート受信確認','バックアップ取得・リストアの検証'] },
    st:    { level:'mid',    items:['本番同等環境でのOS稼働確認','業務アプリケーション起動・停止の確認','夜間バッチ・定期ジョブの動作確認','パフォーマンス基準値との比較確認'] },
    op:    { level:'senior', items:['OS パッチ適用・カーネルアップデート計画・実施','定期的な OS ヘルスチェック(ディスク / メモリ / CPU)','障害発生時の初動対応・ログ調査・原因特定','OSチューニングパラメータの継続的な見直し','EOL 対応(OS バージョンアップ計画)'] },
  },
  nw: {
    req:   { level:'basic',  items:['帯域・レイテンシ要件の整理','セグメント分割・VLAN 要件のヒアリング','冗長要件(LACP / VRRP)の確認'] },
    bdes:  { level:'mid',    items:['ネットワーク構成図(論理 / 物理)の作成','ルーティング設計(スタティック / OSPF)','FW ポリシーのゾーン設計','LB のヘルスチェック・アルゴリズム選定'] },
    ddes:  { level:'senior', items:['IP アドレス設計・VLAN ID 割り当て表','ACL / セキュリティグループ詳細設計','BGP / AS 番号設計(外部接続時)','証明書・TLS 終端ポイントの設計'] },
    build: { level:'senior', items:['ルーター / FW の CLI 設定投入','LB バーチャルサーバー・プール設定','IDS/IPS シグネチャ適用','設定変更管理・コンフィグバックアップ運用'] },
    ut:    { level:'mid',    items:['ping / traceroute による疎通確認','FW ルール通過テスト(許可 / 拒否)','LB セッション振り分け確認','フェイルオーバー切替時間の計測'] },
    it:    { level:'mid',    items:['インターネット疎通・帯域テスト(iperf)','WAF ルール誤検知の確認','VPN 経由の業務アプリ動作確認'] },
    st:    { level:'mid',    items:['本番トラフィックを想定した通信負荷確認','業務シナリオ全体を通じた NW 疎通確認','冗長系切替時の業務影響確認(フェイルオーバー)','セキュリティポリシー最終確認(FW / ACL)'] },
    op:    { level:'senior', items:['ネットワーク機器のファームウェアアップデート管理','帯域・レイテンシの定期モニタリングと閾値管理','FW ルール・ACL の定期棚卸し・最適化','障害発生時のパケットキャプチャ・原因調査','設定変更管理(コンフィグバックアップ・差分管理)'] },
  },
  cloud: {
    req:   { level:'basic',  items:['マルチクラウド / ハイブリッド要件の整理','コスト試算(概算 TCO)','コンプライアンス要件(GDPR / ISMS)の確認','責任共有モデルの説明・合意'] },
    bdes:  { level:'mid',    items:['VPC / サブネット / AZ 設計','IAM ロール・ポリシー設計','ストレージ種別選定(S3 / EFS / EBS)','マネージドサービス選定(RDS / ElastiCache / SQS)'] },
    ddes:  { level:'senior', items:['IaC テンプレート設計(Terraform / CDK)','CloudTrail / Config / GuardDuty 設定方針','コスト配賦タグ設計','DR 設計(RTO/RPO・リージョン冗長)'] },
    build: { level:'senior', items:['Terraform apply / CI/CD パイプライン構築','Auto Scaling・スポットインスタンス設定','Cost Explorer アラート設定','Well-Architected Review 実施'] },
    ut:    { level:'mid',    items:['IaC の terraform plan / tflint チェック','セキュリティグループ疎通テスト','スケールアウト・イン動作確認','フェイルオーバー(RDS Multi-AZ)確認'] },
    it:    { level:'mid',    items:['アプリケーション負荷テスト(k6 / Locust)','監視ダッシュボード(CloudWatch / Datadog)確認','コスト異常検知アラートのテスト'] },
    st:    { level:'mid',    items:['本番データを用いたシナリオテスト(マスク処理済み)','Auto Scaling 動作の業務影響確認','DR 切替訓練(リージョン冗長)','コスト・セキュリティの最終レビュー(Well-Architected)'] },
    op:    { level:'senior', items:['クラウドコスト最適化(RI / Savings Plans・不要リソース削除)','セキュリティインシデント対応(GuardDuty / SecurityHub アラート調査)','IaC(Terraform)によるインフラ変更管理','SLO / エラーバジェットのモニタリングと改善','定期的な Well-Architected レビューの実施'] },
  },
  ctnr: {
    req:   { level:'none',   items:['コンテナ化対象サービスの選定','オーケストレーター要件(EKS / GKE / セルフホスト)','ステートフル / ステートレス要件の整理'] },
    bdes:  { level:'basic',  items:['Kubernetes クラスター構成(ノード数・サイズ・AZ)','Namespace / RBAC 設計','Ingress / Service Mesh 選定','コンテナレジストリ設計(ECR / Artifact Registry)'] },
    ddes:  { level:'mid',    items:['Helm Chart / Kustomize 設計','Resource Requests / Limits 設計','HPA / VPA / Cluster Autoscaler 設定','Pod Security Standards / Network Policy 設計'] },
    build: { level:'mid',    items:['クラスター構築(eksctl / Terraform)','ArgoCD / Flux による GitOps 構築','Prometheus / Grafana / Loki スタック導入','証明書管理(cert-manager)設定'] },
    ut:    { level:'basic',  items:['Pod 起動・ローリングアップデート確認','Liveness / Readiness Probe 動作確認','HPA スケールアウト確認','kubectl exec によるコンテナ内疎通確認'] },
    it:    { level:'basic',  items:['マイクロサービス間 API 連携確認','カナリアリリース・Blue/Green デプロイ検証','障害注入(chaos engineering 基礎)テスト'] },
    st:    { level:'basic',  items:['本番同等データでのエンドツーエンドシナリオ確認','ローリングアップデート・ロールバックの業務影響確認','Pod 異常終了時の自動復旧確認','監視・アラート通知の最終確認'] },
    op:    { level:'mid',    items:['Kubernetes クラスターのバージョンアップ計画・実施','コンテナイメージの脆弱性スキャン・更新管理','Pod / Node リソース使用率の継続的モニタリング','障害発生時のログ収集・原因調査(kubectl logs / describe)','GitOps によるデプロイパイプラインの維持・改善'] },
  },
  mw: {
    req:   { level:'basic',  items:['業務要件からミドルウェア選定(Apache / Nginx / Tomcat)','ArcserveBackup / Veeam バックアップ要件定義','ライセンス・バージョンサポート確認'] },
    bdes:  { level:'mid',    items:['Web サーバー冗長構成(Active-Active / Active-Standby)','AP サーバークラスター設計(Tomcat セッションレプリケーション)','バックアップポリシー設計(フル・差分・増分・世代管理)','ログローテーション・保管期間設計'] },
    ddes:  { level:'mid',    items:['Apache / Nginx 設定ファイル詳細設計(VirtualHost / SSL / mod_security)','Tomcat JVM チューニングパラメータ設計','ArcserveBackup ジョブ・メディアプール詳細設計','証明書管理・自動更新(ACME)設計'] },
    build: { level:'senior', items:['Ansible Playbook による MW 一括インストール・設定','SSL/TLS 証明書インストール・HTTPS 強制','Tomcat JNDI データソース設定','バックアップジョブ作成・スケジュール設定','パフォーマンスチューニング(スレッド数・キープアライブ)'] },
    ut:    { level:'mid',    items:['HTTP レスポンスコード・レスポンスタイム確認','SSL 証明書有効期限・暗号スイート確認','バックアップジョブ正常終了・ログ確認','AP フェイルオーバー確認(Tomcat)'] },
    it:    { level:'mid',    items:['Web - AP - DB 3層連携の動作確認','バックアップリストアの業務システムへの影響確認','負荷試験(Apache Bench / JMeter)結果確認','セキュリティスキャン(OWASP ZAP 基礎)結果確認'] },
    st:    { level:'mid',    items:['本番シナリオでの業務アプリ動作確認','ピーク負荷時のレスポンスタイム確認','バックアップ・リストア手順の最終確認','SSL 証明書・セキュリティ設定の最終確認'] },
    op:    { level:'senior', items:['SSL 証明書の有効期限管理・更新対応','MW パッチ適用・バージョンアップ計画・実施','アクセスログ・エラーログの定期分析','バックアップジョブの成否確認・世代管理','パフォーマンス劣化時のチューニング対応'] },
  },
  hv: {
    req:   { level:'basic',  items:['仮想化方式(VMware vSphere / Hyper-V / KVM)の要件整理','クラスター構成・HA / DRS 要件のヒアリング','ライセンス体系(vSphere Edition)の確認と提案'] },
    bdes:  { level:'mid',    items:['vCenter / ESXi ホスト構成設計(クラスター・ホスト台数・リソース配分)','ネットワーク設計(vSwitch / dvSwitch / VLAN)','共有ストレージ設計(vSAN / NFS / iSCSI)','HA・DRS・vMotion ポリシー設計'] },
    ddes:  { level:'mid',    items:['VM テンプレート・スナップショットポリシー設計','リソースプール・フォルダ構成の詳細設計','バックアップ方式設計(Veeam / VMware Backup)','ライセンス割り当て・vCenter 権限設計'] },
    build: { level:'senior', items:['ESXi ホストインストール・vCenter 構築','vSAN クラスター構成・ネットワーク設定','VM デプロイ自動化(PowerCLI / Ansible)','Veeam バックアップジョブ設定・テスト','監視設定(vRealize Operations / Zabbix 連携)'] },
    ut:    { level:'mid',    items:['VM 起動・停止・スナップショット動作確認','vMotion・Storage vMotion の動作確認','HA フェイルオーバー確認(ホスト障害シミュレーション)','DRS 自動移行確認'] },
    it:    { level:'mid',    items:['業務 VM とネットワーク・ストレージの疎通確認','バックアップリストアの業務影響確認','リソース競合時の DRS 動作確認','監視アラート受信確認'] },
    st:    { level:'mid',    items:['本番業務シナリオでの VM 稼働確認','HA フェイルオーバー時の業務影響・復旧時間確認','vMotion による無停止移行の業務影響確認','バックアップ世代管理・長期保管の最終確認'] },
    op:    { level:'senior', items:['ESXi / vCenter のパッチ・バージョンアップ管理','VM リソース使用率の定期モニタリングと最適化','スナップショット肥大化の管理・定期削除','障害発生時の VM 復旧対応(HA / バックアップリストア)','ライセンス更新・キャパシティプランニング'] },
  },
  hw: {
    req:   { level:'none',   items:['ハードウェア要件定義(サーバー / ストレージ / スイッチ スペック選定)','データセンター要件(ラック・電源・冷却・回線)のヒアリング','保守契約・サポート期限の確認'] },
    bdes:  { level:'basic',  items:['ラックマウント構成設計(U 数・重量・電源系統)','ケーブリング設計(LAN / FC / 電源)','冗長電源・UPS 設計','IPMI / iDRAC / iLO リモート管理設計'] },
    ddes:  { level:'basic',  items:['ラックレイアウト図・ケーブル配線図の作成','BIOS / UEFI 設定方針(Boot order・セキュアブート)','RAID 構成設計(RAID1 / RAID5 / RAID6 / RAID10)','ファームウェアバージョン管理方針'] },
    build: { level:'mid',    items:['サーバー物理搭載・ケーブリング','RAID コントローラー設定・ディスク初期化','BIOS / UEFI・ファームウェアアップデート','iDRAC / iLO 初期設定・ネットワーク設定','電源投入・POST 確認・初期診断'] },
    ut:    { level:'mid',    items:['ハードウェア診断(memtest / ディスク診断)','冗長電源切替テスト','RAID 縮退・リビルド動作確認','リモート管理(iDRAC / iLO)接続・KVM 動作確認'] },
    it:    { level:'basic',  items:['OS インストール後の疎通確認','ラック内温度・電力消費の確認','監視エージェントからのハードウェアアラート受信確認','データセンター入退室・作業手順の確認'] },
    st:    { level:'basic',  items:['本番稼働前の最終ハードウェア診断','長時間稼働テスト(連続稼働 72h 確認)','電源・冷却の安定稼働確認','保守交換手順・緊急対応フローの最終確認'] },
    op:    { level:'mid',    items:['ハードウェア障害アラートの監視・初動対応(ディスク / メモリ / PSU)','部品交換対応(ホットスワップ / 部品手配・DC作業)','ファームウェア・BIOS アップデート管理','保守契約・サポート期限の管理と更新手続き','定期的なハードウェア診断・予防保守の実施'] },
  },
};

// ── Member Switcher ──────────────────────────────────────
function renderMemberTabs() {
  var container = document.getElementById('memberTabs');
  container.innerHTML = '';
  memberList.forEach(function(mid) {
    var tab = document.createElement('div');
    tab.className = 'member-tab' + (mid === currentMemberId ? ' active' : '');
    tab.innerHTML = mid + '<span class="member-tab-del" onclick="confirmDeleteMember(\'' + mid + '\',event)">×</span>';
    tab.addEventListener('click', function(e) {
      if (e.target.classList.contains('member-tab-del')) return;
      switchMember(mid);
    });
    container.appendChild(tab);
  });
}

function switchMember(mid) {
  if (mid === currentMemberId) return;
  currentMemberId = mid;
  renderMemberTabs();
  // データ読み込み
  google.script.run
    .withSuccessHandler(function(result) {
      if (result && result.cells && Object.keys(result.cells).length > 0) {
        memberName = result.memberName || mid;
        data = result.cells;
      } else {
        memberName = mid;
        data = JSON.parse(JSON.stringify(DEFAULT));
      }
      var el = document.getElementById('memberName');
      if (el) el.textContent = memberName || '';
      renderGrid();
    })
    .withFailureHandler(function(err) {
      console.error(err);
      data = JSON.parse(JSON.stringify(DEFAULT));
      renderGrid();
    })
    .getData(mid);
}

function promptAddMember() {
  var mid = prompt('メンバーIDを入力してください(半角英数字推奨)\n例: tanaka / suzuki');
  if (!mid) return;
  mid = mid.trim();
  if (!mid) return;
  if (memberList.indexOf(mid) !== -1) { showToast('⚠ すでに存在します'); return; }

  google.script.run
    .withSuccessHandler(function(res) {
      if (res === 'exists') { showToast('⚠ すでに存在します'); return; }
      memberList.push(mid);
      renderMemberTabs();
      switchMember(mid);
      showToast('' + mid + ' を追加しました');
    })
    .withFailureHandler(function(err) { showToast('⚠ 追加失敗: ' + err.message); })
    .addMember(mid);
}

function confirmDeleteMember(mid, e) {
  e.stopPropagation();
  if (memberList.length <= 1) { showToast('⚠ 最後のメンバーは削除できません'); return; }
  document.getElementById('confirmOverlay').classList.add('open');
  // 確認ダイアログのタイトルを書き換え
  document.querySelector('.confirm-title').textContent = mid + ' のデータを削除しますか?';
  document.querySelector('.btn-confirm-yes').onclick = function() { execDeleteMember(mid); };
}

function execDeleteMember(mid) {
  closeConfirm();
  google.script.run
    .withSuccessHandler(function() {
      memberList = memberList.filter(function(m){ return m !== mid; });
      if (currentMemberId === mid) {
        switchMember(memberList[0]);
      } else {
        renderMemberTabs();
      }
      showToast('🗑 ' + mid + ' を削除しました', true);
    })
    .withFailureHandler(function(err) { showToast('⚠ 削除失敗: ' + err.message); })
    .deleteMember(mid);
}

// ── Member Name(ヘッダー内の表示名編集)────────────────
function editMemberName() {
  if (!isEdit) return;
  var el = document.getElementById('memberName');
  var input = document.createElement('input');
  input.className = 'member-input';
  input.value = (memberName === '' || !memberName) ? '' : memberName;
  input.placeholder = '表示名を入力...';
  el.replaceWith(input);
  input.focus(); input.select();

  function commit() {
    var val = input.value.trim() || '';
    memberName = val;
    var span = document.createElement('span');
    span.className = 'member-name'; span.id = 'memberName';
    span.textContent = val; span.onclick = editMemberName;
    input.replaceWith(span);
  }
  input.addEventListener('blur', commit);
  input.addEventListener('keydown', function(e) {
    if (e.key === 'Enter')  { e.preventDefault(); input.blur(); }
    if (e.key === 'Escape') { input.value = memberName; input.blur(); }
  });
}

// ── Confirm Dialog(全クリア)────────────────────────────
function confirmClearAll() {
  document.querySelector('.confirm-title').textContent = '全データをクリアしますか?';
  document.querySelector('.btn-confirm-yes').onclick = execClearAll;
  document.getElementById('confirmOverlay').classList.add('open');
}

function closeConfirm() {
  document.getElementById('confirmOverlay').classList.remove('open');
}

function execClearAll() {
  closeConfirm();

  // 全セルを空にする
  data = {};
  SKILLS.forEach(function(skill) {
    data[skill.id] = {};
    PHASES.forEach(function(ph) {
      data[skill.id][ph.id] = { level: 'none', items: [] };
    });
  });

  renderGrid();

  // スプシにも反映
  var currentMemberName = (document.getElementById('memberName') || {}).textContent || memberName;
  google.script.run
    .withSuccessHandler(function() { showToast('🗑 全データをクリアしました', true); })
    .withFailureHandler(function(err) { showToast('⚠ 保存失敗: ' + err.message, true); })
    .saveData(currentMemberId, JSON.stringify({ memberName: currentMemberName, cells: data }));
}

// ── Render ───────────────────────────────────────────────
function renderHeader() {
  var row = document.getElementById('phaseHeader');
  row.innerHTML = '<div></div>';
  PHASES.forEach(function(ph, i) {
    var cell = document.createElement('div');
    cell.className = 'phase-header-cell';
    cell.innerHTML =
      '<div class="phase-chip" style="background:' + ph.bg + ';border-color:' + ph.border + ';color:' + ph.color + '">' +
        '<span class="ph-num">PHASE ' + (i+1) + '</span>' +
        '<span class="ph-name">' + ph.name + '</span>' +
      '</div>';
    row.appendChild(cell);
  });
}

function renderGrid() {
  var container = document.getElementById('gridRows');
  container.innerHTML = '';
  SKILLS.forEach(function(skill, si) {
    var row = document.createElement('div');
    row.className = 'skill-row';
    row.style.cssText = 'animation:fadeUp .35s ease ' + (si * 0.07) + 's both';

    var label = document.createElement('div');
    label.className = 'skill-label';
    label.innerHTML =
      '<div class="skill-indicator" style="background:' + skill.color + '"></div>' +
      '<div class="skill-meta">' +
        '<div class="skill-name" style="color:' + skill.color + '">' + skill.name + '</div>' +
        '<div class="skill-sub">' + skill.sub + '</div>' +
      '</div>';
    row.appendChild(label);

    PHASES.forEach(function(ph, pi) {
      var cellData = (data[skill.id] && data[skill.id][ph.id]) ? data[skill.id][ph.id] : { level:'none', items:[] };
      var lvInfo   = LEVELS.filter(function(l){ return l.id === cellData.level; })[0] || LEVELS[0];

      var cell = document.createElement('div');
      cell.className = 'cell'; cell.dataset.ph = pi;

      if (!cellData.items || cellData.items.length === 0) {
        cell.innerHTML =
          '<button class="cell-edit-btn" onclick="openModal(\'' + skill.id + '\',\'' + ph.id + '\',event)">✏</button>' +
          '<div class="cell-empty">—</div>';
      } else {
        var itemsHtml = cellData.items.map(function(item) {
          return '<div class="cell-item"><div class="cell-item-dot" style="background:' + skill.dotColor + '"></div><span>' + escHtml(item) + '</span></div>';
        }).join('');
        cell.innerHTML =
          '<button class="cell-edit-btn" onclick="openModal(\'' + skill.id + '\',\'' + ph.id + '\',event)">✏</button>' +
          '<div class="cell-level" style="background:' + lvInfo.color + '18;border:1px solid ' + lvInfo.color + '33;color:' + lvInfo.color + '">' + lvInfo.label + '</div>' +
          '<div class="cell-items">' + itemsHtml + '</div>';
      }

      if (isEdit) {
        (function(sid, pid) {
          cell.addEventListener('click', function(){ openModal(sid, pid); });
        })(skill.id, ph.id);
      }
      row.appendChild(cell);
    });
    container.appendChild(row);
  });
}

// ── Edit Mode ────────────────────────────────────────────
function toggleEdit() {
  isEdit = !isEdit;
  document.body.classList.toggle('edit-mode', isEdit);
  var btn = document.getElementById('editBtn');
  btn.textContent = isEdit ? '👁 参照モード' : '✏ 編集モード';
  btn.classList.toggle('active', isEdit);
  renderGrid();
}

// ── Cell Modal ───────────────────────────────────────────
function openModal(skillId, phaseId, e) {
  if (e) e.stopPropagation();
  if (!isEdit) return;
  editing = { skillId: skillId, phaseId: phaseId };
  var skill = SKILLS.filter(function(s){ return s.id === skillId; })[0];
  var ph    = PHASES.filter(function(p){ return p.id === phaseId; })[0];
  var d     = (data[skillId] && data[skillId][phaseId]) ? data[skillId][phaseId] : { level:'none', items:[] };

  document.getElementById('modalTitle').textContent = skill.name + ' × ' + ph.name;
  document.getElementById('modalSub').textContent   = skill.sub;

  var lsel = document.getElementById('levelSelect');
  lsel.innerHTML = LEVELS.map(function(l) {
    var isSel = l.id === d.level;
    var style = isSel ? 'border-color:' + l.color + '44;color:' + l.color + ';background:' + l.color + '12' : '';
    return '<div class="level-opt' + (isSel ? ' selected' : '') + '" style="' + style + '" data-level="' + l.id + '" onclick="selectLevel(\'' + l.id + '\',this)">' + l.label + '</div>';
  }).join('');

  bullets = (d.items || []).slice();
  renderBullets();
  document.getElementById('overlay').classList.add('open');
}

function selectLevel(id, el) {
  document.querySelectorAll('.level-opt').forEach(function(o){ o.classList.remove('selected'); o.removeAttribute('style'); });
  var lv = LEVELS.filter(function(l){ return l.id === id; })[0];
  el.classList.add('selected');
  el.style.cssText = 'border-color:' + lv.color + '44;color:' + lv.color + ';background:' + lv.color + '12';
  el.dataset.level = id;
}

function renderBullets() {
  var ed = document.getElementById('bulletEditor');
  if (bullets.length === 0) {
    ed.innerHTML = '<div style="padding:14px 16px;font-size:11px;color:var(--dim)">項目がありません。下の + ボタンで追加してください。</div>';
    return;
  }
  ed.innerHTML = bullets.map(function(b, i) {
    return '<div class="bullet-row">' +
      '<div class="bullet-num">' + String(i+1).padStart(2,'0') + '</div>' +
      '<input class="bullet-input" value="' + escHtml(b) + '" onkeydown="bulletKey(event,' + i + ')" oninput="bullets[' + i + ']=this.value" placeholder="スキル・経験を入力...">' +
      '<button class="bullet-del" onclick="deleteBullet(' + i + ')" title="削除">×</button>' +
    '</div>';
  }).join('');
}

function escHtml(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

function addBullet() {
  bullets.push('');
  renderBullets();
  var inputs = document.querySelectorAll('.bullet-input');
  if (inputs.length) inputs[inputs.length-1].focus();
}

function deleteBullet(i) { bullets.splice(i, 1); renderBullets(); }

function bulletKey(e, i) {
  if (e.key === 'Enter') {
    e.preventDefault();
    bullets[i] = e.target.value;
    bullets.splice(i+1, 0, '');
    renderBullets();
    setTimeout(function() { var ins = document.querySelectorAll('.bullet-input'); if (ins[i+1]) ins[i+1].focus(); }, 0);
  } else if (e.key === 'Backspace' && !e.target.value && bullets.length > 1) {
    e.preventDefault();
    bullets.splice(i, 1);
    renderBullets();
    setTimeout(function() {
      var ins = document.querySelectorAll('.bullet-input');
      var prev = Math.max(0, i-1);
      if (ins[prev]) { ins[prev].focus(); var len = ins[prev].value.length; ins[prev].setSelectionRange(len,len); }
    }, 0);
  }
}

function closeModal() { document.getElementById('overlay').classList.remove('open'); editing = null; }

function saveCell() {
  if (!editing) return;
  document.querySelectorAll('.bullet-input').forEach(function(inp, i) { bullets[i] = inp.value; });
  var cleanBullets = bullets.filter(function(b){ return b.trim(); });
  var skillId = editing.skillId, phaseId = editing.phaseId;
  if (!data[skillId]) data[skillId] = {};
  var selEl = document.querySelector('.level-opt.selected');
  var lvId  = selEl ? selEl.dataset.level : 'none';
  data[skillId][phaseId] = { level: lvId, items: cleanBullets };
  closeModal();
  renderGrid();
  var currentMemberName = (document.getElementById('memberName') || {}).textContent || memberName;
  google.script.run
    .withSuccessHandler(function() { showToast('✓ 保存しました'); })
    .withFailureHandler(function(err) { showToast('⚠ 保存失敗: ' + err.message); })
    .saveData(currentMemberId, JSON.stringify({ memberName: currentMemberName, cells: data }));
}

function showToast(msg, isDanger) {
  var t = document.createElement('div');
  t.className = 'toast' + (isDanger ? ' danger' : '');
  t.textContent = msg;
  document.body.appendChild(t);
  setTimeout(function() { t.style.opacity='0'; setTimeout(function(){ t.remove(); }, 300); }, 2500);
}

document.getElementById('overlay').addEventListener('click', function(e) { if (e.target === this) closeModal(); });
document.getElementById('confirmOverlay').addEventListener('click', function(e) { if (e.target === this) closeConfirm(); });

// ── Init ─────────────────────────────────────────────────
// まずメンバー一覧を取得してからデータをロード
google.script.run
  .withSuccessHandler(function(members) {
    memberList = (members && members.length > 0) ? members : ['member1'];
    currentMemberId = memberList[0];
    renderMemberTabs();
    renderHeader();

    google.script.run
      .withSuccessHandler(function(result) {
        if (result && result.cells && Object.keys(result.cells).length > 0) {
          memberName = result.memberName || currentMemberId;
          data = result.cells;
        } else {
          memberName = currentMemberId;
          data = JSON.parse(JSON.stringify(DEFAULT));
        }
        var el = document.getElementById('memberName');
        if (el) el.textContent = memberName || '';
        renderGrid();
      })
      .withFailureHandler(function(err) {
        console.error('getData failed:', err);
        data = JSON.parse(JSON.stringify(DEFAULT));
        renderGrid();
      })
      .getData(currentMemberId);
  })
  .withFailureHandler(function(err) {
    console.error('getMembers failed:', err);
    // フォールバック:member1で起動
    memberList = ['member1'];
    currentMemberId = 'member1';
    renderMemberTabs();
    renderHeader();
    data = JSON.parse(JSON.stringify(DEFAULT));
    renderGrid();
  })
  .getMembers();
</script>
</body>
</html>

5. デプロイ

  1. 「デプロイ」→「新しいデプロイ」
  2. 種類:ウェブアプリ
  3. 実行ユーザー:自分
  4. アクセス:組織内全員(社内共有の場合)
  5. 発行されたURLを社内共有

2回目以降の更新はURLを変えずに反映できます。
「デプロイ」→「デプロイを管理」→「✏ 編集」→「新バージョン」を選択してデプロイするだけです。


実装のポイント

google.script.run による非同期通信

GASのHTMLからサーバー側の関数を呼ぶには google.script.run を使います。

// 初期データ読み込み
google.script.run
  .withSuccessHandler(function(result) {
    data = result.cells;
    renderGrid();
  })
  .withFailureHandler(function(err) {
    console.error(err);
    // フォールバック処理
  })
  .getData();

// 保存
google.script.run
  .withSuccessHandler(function() { showToast('✓ 保存しました'); })
  .withFailureHandler(function(err) { showToast('⚠ 保存失敗: ' + err.message); })
  .saveData(JSON.stringify({ memberName, cells: data }));

withSuccessHandler / withFailureHandler でコールバックを渡す形式です。async/await は使えないので注意が必要です。

DEFAULTデータはHTMLに埋め込み

初回起動時(スプレッドシートが空の状態)のサンプルデータはHTML内の var DEFAULT = {...} としてベタ書きしています。

ページ読み込み
  → getData() 呼び出し
  → スプシにデータあり → そちらを表示
  → スプシが空(初回) → DEFAULT を表示

HTMLを更新してDEFAULTを変えても、すでにスプシにデータがある場合は反映されません。リセットしたい場合はスプシのシートを空にするか、アプリの「全クリア」ボタンを使います。


Geminiを使ったスプシ直貼り運用

各セルのスキル内容を手入力するのは大変なので、GeminiにJSON形式で出力させてスプシに直貼りする運用が便利です。

Geminiへのプロンプト例:

# 指示
提供するインフラエンジニアのスキルシートを解析し、以下の「スキル領域 × 工程フェーズ」の56パターン(7領域 × 8工程)を網羅した表データを作成してください。
出力は、スプレッドシートに直接貼り付けられるよう「Markdownの表形式」で出力してください。

# 構成ルール
1. skillIdは [os, nw, cloud, ctnr, mw, hv, hw] の7種類。
   - hv: 仮想化/ハイパーバイザ (VMware, ESXi, vCenter等)
   - hw: ハードウェア (物理搭載, ラッキング, BIOS/RAID設定等)
2. phaseIdは [req, bdes, ddes, build, ut, it, st, op] の8種類。
   - st: システムテスト/本番想定テスト (本番同等環境での確認、シナリオテスト等)
   - op: 運用保守 (監視, パッチ適用, 障害対応, バックアップ運用, 構成変更等)
3. 全56行(7領域×8工程)を1行も省略せず、順番に出力してください。
4. itemsは必ず ["タスクA", "タスクB"] の形式で出力し、実績がない場合は [] としてください。
5. levelは [none, basic, mid, senior, lead] から判定してください。

# 出力ルール(重要)
- カラムの間は必ずパイプ記号「 | 」で区切り、Markdownテーブルとして出力してください。
- 各行で列がズレたり、カラム名がくっついたりしないよう厳密に管理してください。
- セル内での改行は禁止です。
- ヘッダー行と区切り行を必ず含めてください。

# 出力形式
| skillId | phaseId | level | items |
| :--- | :--- | :--- | :--- |
| os | req | none | [] |
| os | bdes | mid | ["OS標準設定方針の策定"] |

# 解析対象のスキルシート
(ここにスキルシートの内容を貼り付けてください)

出力されたJSONをスプシのA〜D列に貼り付けるだけで、アプリ側に反映されます。items 列はJSON配列の文字列形式(["xxx","yyy"])である必要があります。


まとめ

項目 内容
構成 GAS(コード.gs + index.html)+ Googleスプレッドシート
データ保存 スプシ1行1レコード(skillId / phaseId / level / items)
認証 Googleアカウントで管理
費用 無料
拡張性 HTMLのSKILLS / PHASES配列に追記するだけで領域・工程を増やせる

GASはサクッと社内ツールを作るのに非常に向いています。今回の構成はインフラのキャリアパスに限らず、評価シートや技術マトリクスなど様々な用途に応用できると思いますので、ぜひ参考にしてみてください。


参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?