はじめに
エンジニアのキャリアの振り返りやキャリアパスを考える際に、Excelやスプレッドシートにとりあえず経歴をまとめたりして運用しているチームは多いと思います。ただ、「社内で手軽に編集・参照できて、かつモダンな見た目にしたい」「一覧性を高めたい」となると、意外と選択肢が限られてきます。
今回、GAS + スプレッドシートでそれらの要件を満たすような簡単なWebアプリを作成してみました。
(作成に当たってClaudeを存分に活用しました)
- Web画面でぽちぽち編集できる
- スプレッドシートにデータを永続保存
- 社内のGoogleアカウントで認証管理できる
- デプロイURLを変えずに中身を更新できる
- 追加費用ゼロ
完成イメージ
スキル領域 × 工程フェーズ のマトリクスで、各セルに習熟レベルと期待スキルを定義できます。

インフラエンジニア向けの領域として以下を定義しました。
| 領域 | 内容 |
|---|---|
| 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
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. デプロイ
- 「デプロイ」→「新しいデプロイ」
- 種類:ウェブアプリ
- 実行ユーザー:自分
- アクセス:組織内全員(社内共有の場合)
- 発行された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はサクッと社内ツールを作るのに非常に向いています。今回の構成はインフラのキャリアパスに限らず、評価シートや技術マトリクスなど様々な用途に応用できると思いますので、ぜひ参考にしてみてください。