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 ? (

オンボーディング構築中

{genStatus}

) : (
{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)} />