import React, { useState, useEffect, useMemo } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, collection, doc, setDoc, updateDoc, onSnapshot, deleteDoc, serverTimestamp, getDoc } from 'firebase/firestore';
import {
LayoutDashboard, Layers, AlertTriangle, GitCompare, UserCheck, Settings,
Plus, ChevronRight, ChevronDown, CheckCircle, Lightbulb, Loader2, ArrowRight, Save,
Trash2, ArrowUp, ArrowDown, Sparkles, Building2, CheckSquare, UploadCloud, FileText, Check
} from 'lucide-react';
// ==========================================
// Firebase Setup & Config
// ==========================================
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'jobjoin-mvp-app';
// ==========================================
// Gemini API Setup
// ==========================================
const apiKey = ""; // Provided by execution environment
// 新機能: 初期テンプレートの生成 (既存データの読み込み対応)
const generateTemplateJSON = async (profile, existingDataText) => {
const prompt = `あなたはBtoB SaaS「JOBJOIN」の優秀な組織コンサルタントAIです。
以下の企業プロファイル情報${existingDataText ? 'および提供された既存の業務データ' : ''}をもとに、指定された「ターゲット役割(職種)」が通常行う一般的な業務プロセスとタスクの構造(叩き台)を作成してください。
企業サービス: ${profile.service}
ターゲット役割: ${profile.targetRole}
${existingDataText ? `\n【既存の業務データ(参考・ベース)】\n${existingDataText}\n\n※上記のデータを最優先に考慮し、表記揺れの修正、大項目(プロセス)と小項目(タスク)の階層化、不足分の補完を行いつつ綺麗な構造に整理してください。\n` : ''}
以下の要件を満たしたJSON形式のみを出力してください。バッククォート(\`\`\`)などのマークダウン記法は絶対に含めず、純粋なJSON文字列のみを返してください。
- processes: 業務プロセス(大項目)を3〜7個程度
- tasks: 各プロセスに紐づく具体的なタスク(小項目)を3〜7個程度
【出力JSONフォーマット】
{
"processes": [
{
"name": "プロセス名(例: 初回ヒアリング・与件整理)",
"tasks": ["タスク名1", "タスク名2", "タスク名3"]
}
]
}`;
try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
})
});
const result = await fetchWithRetry(response);
const text = result.candidates?.[0]?.content?.parts?.[0]?.text || "";
// Remove potential markdown code blocks
const cleanJsonText = text.replace(/```json/g, '').replace(/```/g, '').trim();
return JSON.parse(cleanJsonText);
} catch (error) {
console.error("Template generation error:", error);
return null;
}
};
const generateTaskSuggestions = async (processName, existingTasks) => {
const existingText = existingTasks.map(t => t.name).join('\n');
const prompt = `あなたはBtoB SaaS「JOBJOIN」の優秀な組織コンサルタントAIです。
業務プロセス「${processName}」において、現在以下のタスクが登録されています。
${existingText || "(まだタスクはありません)"}
このプロセスの文脈を踏まえ、次に続くべき、または不足している具体的なタスクの候補を3〜5個提案してください。
返答は提案するタスク名のみを改行区切りで出力してください。箇条書きの記号(- や ・ など)は不要です。余計な説明も不要です。`;
try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
})
});
const result = await fetchWithRetry(response);
const text = result.candidates?.[0]?.content?.parts?.[0]?.text || "";
return text.split('\n').map(s => s.trim().replace(/^[-・*]\s*/, '')).filter(s => s.length > 0).slice(0, 5);
} catch (error) {
console.error("Task suggestion error:", error);
return ["タスク候補を生成できませんでした(エラー)"];
}
};
const generateAIHint = async (taskName, bottleneckReason, stepId) => {
const stepPrompts = {
'sort': 'このタスクを「無くす」「ツールで自動化する」ためのアイデアや問いかけを2〜3行で出してください。',
'reassign': 'このタスクを「社内の別の担当者・部署に任せる」ための基準や問いかけを2〜3行で出してください。',
'outsource': 'このタスクを「外部委託(BPOなど)する」際のメリット・デメリットや検討ポイントを2〜3行で出してください。',
'train': 'このタスクを「既存社員を育成して任せる」ための教育ステップのヒントを2〜3行で出してください。',
'recruit': '最終手段として「採用する」場合、どのようなスキル要件を絶対に外せないか、問いかけを2〜3行で出してください。'
};
const prompt = `あなたはBtoB SaaS「JOBJOIN」の優秀な組織コンサルタントAIです。
以下のボトルネックタスクについて、打ち手を検討しています。
タスク名: ${taskName}
ボトルネックの理由: ${bottleneckReason}
指示: ${stepPrompts[stepId]}
簡潔に、実務的でコンサルティング感のあるトーンで答えてください。`;
try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
systemInstruction: { parts: [{ text: "BtoB SaaSのプロフェッショナルコンサルタントとして振る舞ってください。" }] }
})
});
const result = await fetchWithRetry(response);
return result.candidates?.[0]?.content?.parts?.[0]?.text || "ヒントを生成できませんでした。";
} catch (error) {
console.error("AI hint error:", error);
return "AIの呼び出し中にエラーが発生しました。";
}
};
const fetchWithRetry = async (response, retries = 3) => {
if (response.ok) return response.json();
if (retries > 0) {
await new Promise(r => setTimeout(r, 1000));
return fetchWithRetry(response, retries - 1);
}
throw new Error('API request failed');
};
// ==========================================
// Main Application Component
// ==========================================
export default function App() {
const [user, setUser] = useState(null);
const [authLoading, setAuthLoading] = useState(true);
// Firestore Data State
const [profile, setProfile] = useState(null);
const [processes, setProcesses] = useState([]);
const [tasks, setTasks] = useState([]);
const [bottlenecks, setBottlenecks] = useState([]);
const [decisions, setDecisions] = useState([]);
// UI State
const [currentPath, setCurrentPath] = useState('loading'); // loading, setup, dashboard, analysis...
const [isInitializing, setIsInitializing] = useState(true);
// Initialize Auth
useEffect(() => {
const initAuth = async () => {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (error) {
console.error("Auth init error:", error);
}
};
initAuth();
const unsubscribe = onAuthStateChanged(auth, (u) => {
setUser(u);
setAuthLoading(false);
});
return () => unsubscribe();
}, []);
// Fetch Data & Profile
useEffect(() => {
if (!user) return;
const fetchProfile = async () => {
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'profile');
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
setProfile(docSnap.data());
setCurrentPath('dashboard');
} else {
setCurrentPath('setup'); // Profile doesn't exist, go to setup
}
setIsInitializing(false);
};
fetchProfile();
const uPath = (col) => collection(db, 'artifacts', appId, 'users', user.uid, col);
const unsubs = [
onSnapshot(uPath('processes'), (s) => setProcesses(s.docs.map(d => ({ id: d.id, ...d.data() }))), console.error),
onSnapshot(uPath('tasks'), (s) => setTasks(s.docs.map(d => ({ id: d.id, ...d.data() }))), console.error),
onSnapshot(uPath('bottlenecks'), (s) => setBottlenecks(s.docs.map(d => ({ id: d.id, ...d.data() }))), console.error),
onSnapshot(uPath('decisions'), (s) => setDecisions(s.docs.map(d => ({ id: d.id, ...d.data() }))), console.error),
];
return () => unsubs.forEach(unsub => unsub());
}, [user]);
if (authLoading || isInitializing) {
return
;
}
return (
{currentPath === 'setup' &&
setCurrentPath('dashboard')} />}
{currentPath === 'dashboard' && }
{currentPath === 'analysis' && }
{currentPath === 'bottleneck' && }
{currentPath === 'decision' && }
{currentPath === 'capability' && }
);
}
// ==========================================
// Views & Layouts
// ==========================================
// --- 新規: 0. 初期セットアップ (SetupView) ---
const SetupView = ({ user, profile, onComplete }) => {
const [formData, setFormData] = useState({
companyName: profile?.companyName || '',
service: profile?.service || '',
scale: profile?.scale || '',
issues: profile?.issues || '',
hasEvaluation: profile?.hasEvaluation || 'no',
targetRole: profile?.targetRole || ''
});
const [generateTemplate, setGenerateTemplate] = useState(!profile); // プロファイルがない初回は自動チェック
const [existingDataText, setExistingDataText] = useState(''); // 既存データのテキスト
const [isGenerating, setIsGenerating] = useState(false);
const [genStatus, setGenStatus] = useState('');
const fileInputRef = React.useRef(null);
const handleChange = (e) => setFormData({ ...formData, [e.target.name]: e.target.value });
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
setExistingDataText(prev => prev + (prev ? '\n\n' : '') + event.target.result);
};
reader.readAsText(file); // CSVやTXTファイルのテキスト内容を読み込む
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.companyName || !formData.targetRole) return;
setIsGenerating(true);
// 1. プロファイルを保存
setGenStatus('企業プロファイルを保存中...');
const profileRef = doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'profile');
await setDoc(profileRef, { ...formData, updatedAt: serverTimestamp() }, { merge: true });
// 2. AIによる業務叩き台の生成 (チェックがある場合のみ)
if (generateTemplate) {
setGenStatus(`${formData.targetRole} の業務・タスクを自動生成中(AIが既存データ等を分析しています)...`);
const templateData = await generateTemplateJSON(formData, existingDataText);
if (templateData && templateData.processes) {
setGenStatus('生成された叩き台をデータベースにセットアップ中...');
let pOrder = 0;
// 既存の最大のorderを取得して、末尾に追加されるようにする
const processesSnap = await getDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'settings', 'profile')); // Dummy for structure, actual max order should be queried, defaulting to 100 for append
let baseOrder = 100;
for (const p of templateData.processes) {
// プロセス作成
const pRef = doc(collection(db, 'artifacts', appId, 'users', user.uid, 'processes'));
await setDoc(pRef, { name: p.name, order: baseOrder + pOrder++, createdAt: serverTimestamp() });
// タスク作成
let tOrder = 0;
for (const tName of p.tasks) {
const tRef = doc(collection(db, 'artifacts', appId, 'users', user.uid, 'tasks'));
await setDoc(tRef, { processId: pRef.id, name: tName, difficulty: 'medium', order: tOrder++, createdAt: serverTimestamp() });
}
}
}
}
setIsGenerating(false);
onComplete(); // 指定の画面へ
};
return (
企業プロファイル & AI初期セットアップ
組織情報とファーストレビュー対象の役割を設定します。ここからAIに業務の叩き台を追加生成させることも可能です。
{isGenerating ? (
) : (
)}
);
};
const Sidebar = ({ currentPath, setCurrentPath }) => {
const navItems = [
{ id: 'dashboard', icon: LayoutDashboard, label: 'ダッシュボード' },
{ id: 'analysis', icon: Layers, label: '1. 業務分解' },
{ id: 'bottleneck', icon: AlertTriangle, label: '2. ボトルネック特定' },
{ id: 'decision', icon: GitCompare, label: '3. 打ち手比較' },
{ id: 'capability', icon: UserCheck, label: '4. 能力・ペルソナ' },
];
return (
{/* JOBJOIN ロゴエリア (白背景) */}
JOBJOINメソッド
{/* Onefamily ロゴ (提供元表示) */}
);
};
const Header = ({ user, profile }) => (
);
const StepNavigation = ({ currentPath }) => {
const steps = [
{ id: 'analysis', label: '業務分解' },
{ id: 'bottleneck', label: 'ボトルネック特定' },
{ id: 'decision', label: '打ち手比較' },
{ id: 'capability', label: '能力・ペルソナ' }
];
const currentIndex = steps.findIndex(s => s.id === currentPath);
// Only show step nav on method pages
if (currentIndex === -1) return null;
return (
{steps.map((step, index) => {
const isActive = index === currentIndex;
const isPast = index < currentIndex;
return (
{isPast ? : index + 1}
{step.label}
{index < steps.length - 1 && (
)}
);
})}
);
};
// ==========================================
// Views
// ==========================================
// --- 0. ダッシュボード (DashboardView) ---
const DashboardView = ({ user, profile, processes, tasks, bottlenecks, decisions, setCurrentPath }) => {
const highBottlenecks = bottlenecks.filter(b => b.score >= 4).length;
const decisionCount = decisions.length;
return (
{/* ヒーローセクション */}
採用は、最後の選択肢。
組織の真のボトルネックを特定する。
JOBJOINは、安易な採用に頼らない強い組織作りをサポートします。
業務を分解し、ボトルネックを特定し、「整理・再配置・委託・育成」の
打ち手を比較検討した上で、本当に必要な能力・ペルソナを設計します。
{/* プロファイルサマリーパネル */}
{profile && (
ファーストレビュー対象
登録業務(大項目): {processes.length} 件
登録タスク(小項目): {tasks.length} 件
{processes.length > 0 && (
AIによる初期叩き台 生成済み
)}
)}
{/* メソッドステップの可視化 */}
JOBJOIN メソッド・プロセス
{/* Step 1 */}
setCurrentPath('analysis')} className="group cursor-pointer bg-white rounded-xl border border-slate-200 p-5 hover:border-[#1a365d] hover:shadow-md transition-all relative overflow-hidden">
業務分解
業務を工程とタスクに分解し、現状を可視化します。
{processes.length} 業務 / {tasks.length} タスク
{/* Step 2 */}
setCurrentPath('bottleneck')} className="group cursor-pointer bg-white rounded-xl border border-slate-200 p-5 hover:border-red-400 hover:shadow-md transition-all relative overflow-hidden">
ボトルネック特定
成果を阻害しているタスクとその原因を言語化します。
影響度大: {highBottlenecks} 件
{/* Step 3 */}
setCurrentPath('decision')} className="group cursor-pointer bg-white rounded-xl border border-slate-200 p-5 hover:border-[#4cb3d4] hover:shadow-md transition-all relative overflow-hidden">
打ち手比較
整理・再配置・委託・育成・採用の順で比較します。
決着済: {decisionCount} 件
{/* Step 4 */}
setCurrentPath('capability')} className="group cursor-pointer bg-white rounded-xl border border-slate-200 p-5 hover:border-indigo-400 hover:shadow-md transition-all relative overflow-hidden">
能力・ペルソナ
必要職能と才能、マイクロスキルを設計します。
未設計
);
};
// --- 1. 業務分解 (AnalysisView) ---
const AnalysisView = ({ user, processes, tasks }) => {
const [newProcessName, setNewProcessName] = useState('');
const [expandedProcess, setExpandedProcess] = useState(null);
// 初期ロード時、もしプロセスが存在すれば最初のものを自動展開する
useEffect(() => {
if (processes.length > 0 && !expandedProcess) {
setExpandedProcess(processes[0].id);
}
}, [processes]);
// プロセスをorder順にソート
const sortedProcesses = useMemo(() => {
return [...processes].sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.createdAt?.toMillis?.() || 0);
const orderB = b.order !== undefined ? b.order : (b.createdAt?.toMillis?.() || 0);
return orderA - orderB;
});
}, [processes]);
const addProcess = async () => {
if (!newProcessName.trim()) return;
// Calculate max order to append at the end
let maxOrder = 0;
if (processes.length > 0) {
maxOrder = Math.max(...processes.map((p, i) => p.order !== undefined ? p.order : i));
}
const ref = doc(collection(db, 'artifacts', appId, 'users', user.uid, 'processes'));
await setDoc(ref, { name: newProcessName.trim(), order: maxOrder + 1, createdAt: serverTimestamp() });
setNewProcessName('');
};
const moveProcess = async (index, direction, e) => {
if (e) e.stopPropagation();
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === sortedProcesses.length - 1) return;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
const currentProcess = sortedProcesses[index];
const targetProcess = sortedProcesses[targetIndex];
const currentOrder = currentProcess.order !== undefined ? currentProcess.order : index;
const targetOrder = targetProcess.order !== undefined ? targetProcess.order : targetIndex;
const newCurrentOrder = currentOrder === targetOrder ? targetOrder + (direction === 'up' ? 1 : -1) : targetOrder;
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'processes', currentProcess.id), { order: newCurrentOrder });
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'processes', targetProcess.id), { order: currentOrder });
};
const deleteProcess = async (processId, e) => {
if (e) e.stopPropagation();
await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'processes', processId));
};
return (
1. 業務分解
部署の業務を「工程」と「タスク」に分解し、可視化します。
{/* Add Process */}
{/* Process List */}
{processes.length === 0 && (
まだ業務が登録されていません。上の入力欄から業務を追加してください。
)}
{sortedProcesses.map((process, index) => (
t.processId === process.id)}
user={user}
isExpanded={expandedProcess === process.id}
onToggle={() => setExpandedProcess(expandedProcess === process.id ? null : process.id)}
index={index}
totalCount={sortedProcesses.length}
onMoveUp={(e) => moveProcess(index, 'up', e)}
onMoveDown={(e) => moveProcess(index, 'down', e)}
onDelete={(e) => deleteProcess(process.id, e)}
/>
))}
);
};
const ProcessCard = ({ process, tasks, user, isExpanded, onToggle, index, totalCount, onMoveUp, onMoveDown, onDelete }) => {
const [newTaskName, setNewTaskName] = useState('');
const [suggestedTasks, setSuggestedTasks] = useState([]);
const [isSuggesting, setIsSuggesting] = useState(false);
// Sort tasks by order or createdAt
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.createdAt?.toMillis?.() || 0);
const orderB = b.order !== undefined ? b.order : (b.createdAt?.toMillis?.() || 0);
return orderA - orderB;
});
}, [tasks]);
const fetchSuggestions = async (currentTasks) => {
setIsSuggesting(true);
const suggestions = await generateTaskSuggestions(process.name, currentTasks);
// すでに存在するタスク名は除外
const filtered = suggestions.filter(s => !currentTasks.some(t => t.name === s));
setSuggestedTasks(filtered);
setIsSuggesting(false);
};
const addTask = async (taskNameToAdd) => {
const name = typeof taskNameToAdd === 'string' ? taskNameToAdd : newTaskName;
if (!name.trim()) return;
const maxOrder = sortedTasks.length > 0 ? Math.max(...sortedTasks.map((t, i) => t.order !== undefined ? t.order : i)) : 0;
const ref = doc(collection(db, 'artifacts', appId, 'users', user.uid, 'tasks'));
await setDoc(ref, {
processId: process.id,
name: name.trim(),
difficulty: 'medium',
order: maxOrder + 1,
createdAt: serverTimestamp()
});
if (typeof taskNameToAdd !== 'string') {
setNewTaskName('');
}
// 提案から追加した場合は、その提案を消す
setSuggestedTasks(prev => prev.filter(t => t !== name.trim()));
// タスク入力後に自動で次の予測を立てる
const updatedTasks = [...sortedTasks, { name: name.trim() }];
fetchSuggestions(updatedTasks);
};
const deleteTask = async (taskId, e) => {
e.stopPropagation();
await deleteDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'tasks', taskId));
};
const moveTask = async (index, direction, e) => {
e.stopPropagation();
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === sortedTasks.length - 1) return;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
const currentTask = sortedTasks[index];
const targetTask = sortedTasks[targetIndex];
const currentOrder = currentTask.order !== undefined ? currentTask.order : index;
const targetOrder = targetTask.order !== undefined ? targetTask.order : targetIndex;
const newCurrentOrder = currentOrder === targetOrder ? targetOrder + (direction === 'up' ? 1 : -1) : targetOrder;
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'tasks', currentTask.id), { order: newCurrentOrder });
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'tasks', targetTask.id), { order: currentOrder });
};
return (
{isExpanded ? : }
{process.name}
{/* プロセス移動・削除ボタン */}
{tasks.length} タスク
{isExpanded && (
{sortedTasks.length === 0 ? (
タスクがありません。追加してください。
) : (
sortedTasks.map((task, index) => (
))
)}
{/* AI Suggestions */}
{isSuggesting && (
AIが次のタスクを予測中...
)}
{!isSuggesting && suggestedTasks.length > 0 && (
AIが予測した次のタスク候補
{suggestedTasks.map((sTask, idx) => (
))}
)}
setNewTaskName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
)}
);
};
// --- 2. ボトルネック特定 (BottleneckView) ---
const BottleneckView = ({ user, processes, tasks, bottlenecks, setCurrentPath }) => {
const [selectedTask, setSelectedTask] = useState(null);
// Group and sort processes
const sortedProcesses = useMemo(() => {
return [...processes].sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.createdAt?.toMillis?.() || 0);
const orderB = b.order !== undefined ? b.order : (b.createdAt?.toMillis?.() || 0);
return orderA - orderB;
});
}, [processes]);
// Group and sort tasks within processes
const processWithTasks = useMemo(() => {
return sortedProcesses.map(p => {
const pTasks = tasks.filter(t => t.processId === p.id);
const sortedTasks = [...pTasks].sort((a, b) => {
const orderA = a.order !== undefined ? a.order : (a.createdAt?.toMillis?.() || 0);
const orderB = b.order !== undefined ? b.order : (b.createdAt?.toMillis?.() || 0);
return orderA - orderB;
});
return { ...p, tasks: sortedTasks };
}).filter(p => p.tasks.length > 0);
}, [sortedProcesses, tasks]);
const moveProcess = async (index, direction, e) => {
if (e) e.stopPropagation();
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === processWithTasks.length - 1) return;
const targetIndex = direction === 'up' ? index - 1 : index + 1;
const currentProcess = processWithTasks[index];
const targetProcess = processWithTasks[targetIndex];
const currentOrder = currentProcess.order !== undefined ? currentProcess.order : index;
const targetOrder = targetProcess.order !== undefined ? targetProcess.order : targetIndex;
const newCurrentOrder = currentOrder === targetOrder ? targetOrder + (direction === 'up' ? 1 : -1) : targetOrder;
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'processes', currentProcess.id), { order: newCurrentOrder });
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'processes', targetProcess.id), { order: currentOrder });
};
const moveTask = async (processIndex, taskIndex, direction, e) => {
if (e) e.stopPropagation();
const p = processWithTasks[processIndex];
if (direction === 'up' && taskIndex === 0) return;
if (direction === 'down' && taskIndex === p.tasks.length - 1) return;
const targetIndex = direction === 'up' ? taskIndex - 1 : taskIndex + 1;
const currentTask = p.tasks[taskIndex];
const targetTask = p.tasks[targetIndex];
const currentOrder = currentTask.order !== undefined ? currentTask.order : taskIndex;
const targetOrder = targetTask.order !== undefined ? targetTask.order : targetIndex;
const newCurrentOrder = currentOrder === targetOrder ? targetOrder + (direction === 'up' ? 1 : -1) : targetOrder;
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'tasks', currentTask.id), { order: newCurrentOrder });
await updateDoc(doc(db, 'artifacts', appId, 'users', user.uid, 'tasks', targetTask.id), { order: currentOrder });
};
return (
{/* Left: Task List */}
2. ボトルネック特定
組織の成果を阻害しているタスク(ボトルネック)を特定し、原因を言語化します。
{processWithTasks.length === 0 && (
先に「業務分解」でタスクを登録してください。
)}
{processWithTasks.map((process, pIdx) => (
{process.name}
{process.tasks.map((task, tIdx) => {
const bNeck = bottlenecks.find(b => b.taskId === task.id);
const isSelected = selectedTask?.id === task.id;
return (
setSelectedTask(task)}
className={`p-3 rounded-lg border cursor-pointer transition-all relative group/task ${
isSelected
? 'border-indigo-500 ring-1 ring-indigo-500 bg-indigo-50/30'
: 'border-slate-200 bg-white hover:border-indigo-300 hover:shadow-sm'
}`}
>
{task.name}
{bNeck && (
= 4 ? 'bg-red-100 text-red-700' : 'bg-orange-100 text-orange-700'
}`}>
影響度: {bNeck.score}
)}
{bNeck?.reason && (
{bNeck.reason}
)}
{/* タスク移動ボタン */}
);
})}
))}
{/* Right: Evaluation Form */}
{selectedTask ? (
b.taskId === selectedTask.id)}
onNext={() => setCurrentPath('decision')}
/>
) : (
左側のリストからタスクを選択して、
ボトルネックの評価を行ってください。
)}
);
};
const BottleneckForm = ({ user, task, existingData, onNext }) => {
const [score, setScore] = useState(existingData?.score || 3);
const [reason, setReason] = useState(existingData?.reason || '');
// 新規追加: タスク詳細属性
const [workload, setWorkload] = useState(existingData?.workload || 'medium'); // short, medium, long
const [importance, setImportance] = useState(existingData?.importance || 'medium'); // low, medium, high
const [difficulty, setDifficulty] = useState(existingData?.difficulty || 'months'); // days, months, year, years
const [mentalLoad, setMentalLoad] = useState(existingData?.mentalLoad || 'medium'); // low, medium, high
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setScore(existingData?.score || 3);
setReason(existingData?.reason || '');
setWorkload(existingData?.workload || 'medium');
setImportance(existingData?.importance || 'medium');
setDifficulty(existingData?.difficulty || 'months');
setMentalLoad(existingData?.mentalLoad || 'medium');
}, [task, existingData]);
const handleSave = async () => {
setIsSaving(true);
const id = existingData?.id || `bn_${task.id}`;
const ref = doc(db, 'artifacts', appId, 'users', user.uid, 'bottlenecks', id);
await setDoc(ref, {
taskId: task.id,
taskName: task.name,
score,
reason,
workload,
importance,
difficulty,
mentalLoad,
updatedAt: serverTimestamp()
});
setIsSaving(false);
};
return (
{/* 新規: タスク属性評価パネル */}
タスクの特性評価
{[1, 2, 3, 4, 5].map(val => (
))}
1: 全く問題ない
5: 致命的なボトルネック
{score >= 3 && (
)}
);
};
// --- 3. 打ち手比較 (DecisionView) ---
// JOBJOINの絶対ルール: 採用は最後の選択肢。整理→再配置→委託→育成→採用 の順で検討させる。
const DECISION_STEPS = [
{ id: 'sort', label: '1. 整理', desc: '無くせないか?統合・自動化できないか?', color: 'bg-blue-500' },
{ id: 'reassign', label: '2. 再配置', desc: '社内の別の人や部署で対応できないか?', color: 'bg-cyan-500' },
{ id: 'outsource', label: '3. 委託', desc: '外部ツールやBPOに外注できないか?', color: 'bg-teal-500' },
{ id: 'train', label: '4. 育成', desc: '既存社員を教育・育成して解決できないか?', color: 'bg-emerald-500' },
{ id: 'recruit', label: '5. 採用', desc: 'どうしても採用が必要な場合のみ検討。', color: 'bg-[#1a365d]' },
];
const DecisionView = ({ user, bottlenecks, decisions }) => {
// Only show high score bottlenecks
const targetBottlenecks = bottlenecks.filter(b => b.score >= 3).sort((a,b) => b.score - a.score);
const [selectedBnId, setSelectedBnId] = useState(targetBottlenecks[0]?.id || null);
const selectedBn = targetBottlenecks.find(b => b.id === selectedBnId);
const existingDecision = decisions.find(d => d.bottleneckId === selectedBnId);
return (
3. 打ち手比較 (Action Decision)
安易な採用は組織の歪みを生みます。JOBJOINの絶対ルールに従い、左から順に打ち手を検討してください。
{targetBottlenecks.length === 0 ? (
比較対象のボトルネックがありません。「2. ボトルネック特定」で影響度3以上のタスクを設定してください。
) : (
{/* Tabs for Bottlenecks (改善) */}
{targetBottlenecks.map(bn => {
// 決着済みかどうかを判定
const isDecided = decisions.some(d => d.bottleneckId === bn.id && d.finalDecision);
const isSelected = selectedBnId === bn.id;
return (
);
})}
{/* Wizard Content */}
{selectedBn && (
)}
)}
);
};
const DecisionWizard = ({ user, bottleneck, existingData }) => {
// Store considerations for each step
const [notes, setNotes] = useState(existingData?.notes || {
sort: '', reassign: '', outsource: '', train: '', recruit: ''
});
const [finalDecision, setFinalDecision] = useState(existingData?.finalDecision || null);
const [currentStepIdx, setCurrentStepIdx] = useState(0);
const [aiHints, setAiHints] = useState({});
const [loadingAi, setLoadingAi] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
setNotes(existingData?.notes || { sort: '', reassign: '', outsource: '', train: '', recruit: '' });
setFinalDecision(existingData?.finalDecision || null);
setCurrentStepIdx(0);
setAiHints({});
setSaveSuccess(false);
}, [bottleneck.id, existingData]);
const updateNote = (stepId, value) => {
setNotes(prev => ({ ...prev, [stepId]: value }));
};
const fetchHint = async (stepId) => {
setLoadingAi(true);
const hint = await generateAIHint(bottleneck.taskName, bottleneck.reason, stepId);
setAiHints(prev => ({ ...prev, [stepId]: hint }));
setLoadingAi(false);
};
const handleSave = async () => {
setIsSaving(true);
const id = existingData?.id || `dec_${bottleneck.id}`;
const ref = doc(db, 'artifacts', appId, 'users', user.uid, 'decisions', id);
await setDoc(ref, {
bottleneckId: bottleneck.id,
notes,
finalDecision,
updatedAt: serverTimestamp()
});
setIsSaving(false);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000); // 3秒後に成功メッセージを消す
};
const currentStep = DECISION_STEPS[currentStepIdx];
const isLastStep = currentStepIdx === DECISION_STEPS.length - 1;
return (
{/* Wizard Progress Header */}
{DECISION_STEPS.map((step, idx) => {
const isPassed = idx < currentStepIdx;
const isActive = idx === currentStepIdx;
return (
);
})}
{/* Left: Step Content */}
{currentStepIdx + 1}
{currentStep.label} の検討
{currentStep.desc}
現在の課題認識:
{bottleneck.reason || "(理由が入力されていません)"}
{aiHints[currentStep.id] && (
{aiHints[currentStep.id]}
)}
{currentStepIdx > 0 ? (
) :
}
{!isLastStep ? (
) : (
すべての検討が完了しました
)}
{/* Right: Final Decision Panel */}
最終結論の決定
すべてのステップを検討した上で、このタスクに対する最終的な打ち手を選択してください。
{DECISION_STEPS.map(step => (
))}
{/* 保存成功フィードバック */}
{saveSuccess && (
保存しました
)}
);
};
// --- 4. 能力・ペルソナ (CapabilityView) ---
// 新規追加: 「育成」「採用」が選ばれたタスクの能力設計画面
const CapabilityView = ({ user, decisions, bottlenecks, tasks }) => {
// 打ち手比較で「育成(train)」または「採用(recruit)」が選ばれたものだけを抽出
const targetDecisions = decisions.filter(d => d.finalDecision === 'train' || d.finalDecision === 'recruit');
const [selectedDecId, setSelectedDecId] = useState(targetDecisions[0]?.id || null);
const selectedDecision = targetDecisions.find(d => d.id === selectedDecId);
// ボトルネック情報からタスク名を取得
const selectedBn = bottlenecks.find(b => b.id === selectedDecision?.bottleneckId);
return (
{/* Left: Target List */}
4. 能力・ペルソナ
「育成」「採用」が必要と判断されたタスクに対して、必要な能力(職能・才能・マイクロスキル)を言語化します。
{targetDecisions.length === 0 ? (
対象タスクがありません。「打ち手比較」で「育成」または「採用」と結論づけられたタスクがここに表示されます。
) : (
targetDecisions.map(dec => {
const bn = bottlenecks.find(b => b.id === dec.bottleneckId);
const isSelected = selectedDecId === dec.id;
const isRecruit = dec.finalDecision === 'recruit';
return (
setSelectedDecId(dec.id)}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
isSelected
? `border-${isRecruit ? 'indigo' : 'emerald'}-500 ring-1 ring-${isRecruit ? 'indigo' : 'emerald'}-500 bg-white shadow-sm`
: 'border-slate-200 bg-slate-50 hover:bg-white'
}`}
>
{isRecruit ? '採用要件' : '育成設計'}
{bn?.taskName || "不明なタスク"}
);
})
)}
{/* Right: Capability Form */}
{selectedDecision && selectedBn ? (
) : (
左側のリストからタスクを選択して、
必要能力の設計を行ってください。
)}
);
};
// 簡易版の能力入力フォーム(MVP用モックアップ)
const CapabilityForm = ({ user, decision, bottleneck }) => {
const [competency, setCompetency] = useState(''); // 職能
const [talents, setTalents] = useState(''); // 才能(テキストベース)
const [microSkills, setMicroSkills] = useState(''); // マイクロスキル(箇条書き)
// ここで既存の能力データをフェッチする処理が入る想定ですが、
// MVPプレビュー用に今はローカルステートのみで表示しています。
return (
[{decision.finalDecision === 'recruit' ? '採用' : '育成'}のターゲットタスク]
{bottleneck.taskName}
ボトルネック理由: {bottleneck.reason}
setCompetency(e.target.value)}
/>
);
};