import React, { useState, useMemo, useEffect, useRef } from ‘react’;
import {
Users,
Search,
Plus,
MapPin,
Calendar,
ArrowLeft,
History,
Sparkles,
Send,
Loader2,
Phone,
Map as MapIcon,
AlertTriangle,
Edit,
Save,
Briefcase,
FileText,
Download,
Paperclip,
Info
} from ‘lucide-react’;
// — API 設定 —
// 有効なAPIキーをここに設定してください。設定されていない場合はプレースホルダーが表示されます。
const apiKey = “”;
// — 初期モックデータ —
const INITIAL_CUSTOMERS = [
{
id: “1”,
name: “山田 太郎”,
zipCode: “100-0001”,
address: “東京都千代田区千代田1-1”,
phone: “090-1234-5678”,
rank: “優良”,
buildDate: “2010-05-20”,
manager: “佐藤 健一”,
status: “交渉中”,
lat: 35.6812,
lng: 139.7671,
projects: [
{ id: “p1”, name: “屋根・外壁塗装工事”, status: “見積提示済”, date: “2024-02-15”, attachment: “御見積書_山田様.pdf” }
],
history: [
{ id: “h1”, date: “2024-01-10”, note: “定期点検の案内。屋根の色褪せを気にされていた。” }
]
},
{
id: “2”,
name: “鈴木 花子”,
zipCode: “530-0001”,
address: “大阪府大阪市北区梅田1-1”,
phone: “080-9876-5432”,
rank: “要注意”,
buildDate: “1995-11-01”,
manager: “高橋 誠”,
status: “未着手”,
lat: 34.7024,
lng: 135.4959,
projects: [],
history: []
},
{
id: “3”,
name: “田中 次郎”,
zipCode: “150-0002”,
address: “東京都渋谷区渋谷2-2-2”,
phone: “03-1111-2222”,
rank: “優良”,
buildDate: “2020-01-15”,
manager: “佐藤 健一”,
status: “完了”,
lat: 35.6580,
lng: 139.7016,
projects: [
{ id: “p2”, name: “システムキッチン入替”, status: “完工”, date: “2023-10-05”, attachment: “完了報告書.pdf” }
],
history: [
{ id: “h2”, date: “2023-11-20”, note: “完工後の1ヶ月点検。特に問題なし、満足されている様子。” }
]
}
];
const App = () => {
const [customers, setCustomers] = useState(INITIAL_CUSTOMERS);
const [activeTab, setActiveTab] = useState(‘customers’); // customers, map, projects, history
const [view, setView] = useState(‘list’); // list, detail, form
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [editingCustomer, setEditingCustomer] = useState(null);
const [searchTerm, setSearchTerm] = useState(”);
const [filterRank, setFilterRank] = useState(‘すべて’);
const [isAiLoading, setIsAiLoading] = useState(false);
const [aiResponse, setAiResponse] = useState(“”);
const [aiModalOpen, setAiModalOpen] = useState(false);
const [mapError, setMapError] = useState(false);
// 投稿用ステート
const [newProject, setNewProject] = useState({ name: ”, status: ‘ヒアリング’, attachment: ” });
const [newHistory, setNewHistory] = useState({ date: new Date().toISOString().split(‘T’)[0], note: ” });
// Google Maps 関連
const mapRef = useRef(null);
const googleMapRef = useRef(null);
useEffect(() => {
if (activeTab !== ‘map’) return;
const loadGoogleMaps = () => {
// APIキーがない場合は即座にエラー状態にする
if (!apiKey) {
setMapError(true);
return;
}
if (window.google && window.google.maps) {
initMap();
return;
}
// エラー発生時のコールバック
window.gm_authFailure = () => {
console.error(“Google Maps API authentication failed (gm_authFailure).”);
setMapError(true);
};
const script = document.createElement(‘script’);
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=initMap`;
script.async = true;
script.defer = true;
script.onerror = () => {
console.error(“Google Maps script load error.”);
setMapError(true);
};
window.initMap = initMap;
document.head.appendChild(script);
};
const initMap = () => {
if (!mapRef.current) return;
try {
const mapOptions = {
center: { lat: 35.6812, lng: 139.7671 },
zoom: 12,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
styles: [{ featureType: “poi”, elementType: “labels”, stylers: [{ visibility: “off” }] }]
};
const map = new window.google.maps.Map(mapRef.current, mapOptions);
googleMapRef.current = map;
customers.forEach(customer => {
const markerColor = customer.status === ‘完了’ ? ‘green’ : customer.status === ‘交渉中’ ? ‘blue’ : ‘grey’;
const marker = new window.google.maps.Marker({
position: { lat: customer.lat, lng: customer.lng },
map: map,
title: customer.name,
icon: `https://maps.google.com/mapfiles/ms/icons/${markerColor}-dot.png`
});
const infoWindow = new window.google.maps.InfoWindow({
content: `
${customer.name} 様
${customer.address}
`
});
marker.addListener(‘click’, () => {
infoWindow.open(map, marker);
setTimeout(() => {
const btn = document.getElementById(`btn-detail-${customer.id}`);
if (btn) {
btn.onclick = () => {
setSelectedCustomer(customer);
setView(‘detail’);
setActiveTab(‘customers’);
};
}
}, 100);
});
});
} catch (e) {
console.error(“Map initialization failed:”, e);
setMapError(true);
}
};
loadGoogleMaps();
return () => {
// クリーンアップ
delete window.initMap;
delete window.gm_authFailure;
};
}, [activeTab, customers]);
const callGemini = async (prompt) => {
setIsAiLoading(true);
setAiModalOpen(true);
setAiResponse(“”);
let retries = 0;
while (retries < 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 }] }],
systemInstruction: { parts: [{ text: "住宅営業のプロとしてアドバイスしてください。" }] }
})
});
if (!response.ok) throw new Error('API request failed');
const data = await response.json();
setAiResponse(data.candidates?.[0]?.content?.parts?.[0]?.text || "回答を取得できませんでした。");
setIsAiLoading(false);
return;
} catch (error) {
retries++;
await new Promise(res => setTimeout(res, Math.pow(2, retries) * 1000));
}
}
setIsAiLoading(false);
setAiResponse(“AIとの通信に失敗しました。APIキーを確認してください。”);
};
const calculateAge = (date) => {
if (!date) return “不明”;
const buildDate = new Date(date);
if (isNaN(buildDate)) return “不明”;
const age = new Date().getFullYear() – buildDate.getFullYear();
return `${age}年`;
};
const filteredCustomers = useMemo(() => {
return customers.filter(c => {
const matchSearch = c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.address.toLowerCase().includes(searchTerm.toLowerCase());
const matchRank = filterRank === ‘すべて’ || c.rank === filterRank;
return matchSearch && matchRank;
});
}, [customers, searchTerm, filterRank]);
// 全顧客の案件と履歴をフラットにしたリスト(全体タブ用)
const allProjects = useMemo(() => {
return customers.flatMap(c => (c.projects || []).map(p => ({ …p, customerName: c.name, customerId: c.id })))
.sort((a, b) => new Date(b.date) – new Date(a.date));
}, [customers]);
const allHistory = useMemo(() => {
return customers.flatMap(c => (c.history || []).map(h => ({ …h, customerName: c.name, customerId: c.id })))
.sort((a, b) => new Date(b.date) – new Date(a.date));
}, [customers]);
const StatusBadge = ({ status }) => {
const colors = {
‘完了’: ‘bg-green-100 text-green-700 border-green-200’,
‘完工’: ‘bg-green-100 text-green-700 border-green-200’,
‘交渉中’: ‘bg-blue-100 text-blue-700 border-blue-200’,
‘見積提示済’: ‘bg-blue-100 text-blue-700 border-blue-200’,
‘ヒアリング’: ‘bg-amber-100 text-amber-700 border-amber-200’,
‘未着手’: ‘bg-slate-100 text-slate-600 border-slate-200’,
};
const appliedClass = colors[status] || ‘bg-slate-100 text-slate-600 border-slate-200’;
return
{status};
};
// CSVダウンロード機能 (BOM付きで文字化け防止)
const exportCSV = () => {
const headers = [‘氏名’, ‘郵便番号’, ‘住所’, ‘電話番号’, ‘ランク’, ‘担当者’, ‘ステータス’];
const rows = filteredCustomers.map(c => [
c.name, c.zipCode, c.address, c.phone, c.rank, c.manager, c.status
]);
const csvContent = “data:text/csv;charset=utf-8,\uFEFF”
+ [headers.join(‘,’), …rows.map(e => e.map(item => `”${item}”`).join(‘,’))].join(‘\n’);
const encodedUri = encodeURI(csvContent);
const link = document.createElement(“a”);
link.setAttribute(“href”, encodedUri);
link.setAttribute(“download”, “customer_list.csv”);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 投稿アクション
const handleAddProject = (customerId) => {
if (!newProject.name) return;
const projectEntry = { id: Date.now().toString(), …newProject, date: new Date().toISOString().split(‘T’)[0] };
setCustomers(prev => prev.map(c => c.id === customerId ? { …c, projects: [projectEntry, …(c.projects || [])] } : c));
setNewProject({ name: ”, status: ‘ヒアリング’, attachment: ” });
setSelectedCustomer(prev => ({ …prev, projects: [projectEntry, …(prev.projects || [])] }));
};
const handleAddHistory = (customerId) => {
if (!newHistory.note) return;
const historyEntry = { id: Date.now().toString(), …newHistory };
setCustomers(prev => prev.map(c => c.id === customerId ? { …c, history: [historyEntry, …(c.history || [])] } : c));
setNewHistory({ date: new Date().toISOString().split(‘T’)[0], note: ” });
setSelectedCustomer(prev => ({ …prev, history: [historyEntry, …(prev.history || [])] }));
};
return (
{/* ナビゲーション */}
{/* — タブ: 顧客マップ — */}
{activeTab === ‘map’ && (
{mapError ? (
地図を読み込めません
Google Maps APIの設定(ApiProjectMapError)またはAPIキーが未入力のため、地図を表示できません。
※コード内の apiKey 変数に有効なキーを設定してください。
顧客の現在地一覧
{customers.map(c => (
{c.name} 様
{c.address.substring(0, 10)}…
))}
) : (
)}
{!mapError && (
)}
)}
{/* — タブ: 顧客マスター (一覧) — */}
{activeTab === ‘customers’ && view === ‘list’ && (
{[‘すべて’, ‘優良’, ‘要注意’, ‘訪問不要’].map(r => (
))}
{filteredCustomers.length > 0 ? filteredCustomers.map(customer => (
{setSelectedCustomer(customer); setView(‘detail’);}}
className=”bg-white p-4 rounded-xl shadow-sm border border-slate-200 hover:border-indigo-300 transition cursor-pointer”
>
{customer.name[0]}
{customer.name} 様
{customer.address}
ランク: {customer.rank}
築{calculateAge(customer.buildDate)}
)) : (
)}
)}
{/* — タブ: 案件情報 (全顧客横断) — */}
{activeTab === ‘projects’ && (
案件タイムライン
全顧客のプロジェクト進捗状況を時系列で表示しています
{allProjects.length === 0 ? (
案件情報はありません
) : (
{allProjects.map(p => (
{
const cust = customers.find(c => c.id === p.customerId);
if(cust){ setSelectedCustomer(cust); setView(‘detail’); setActiveTab(‘customers’); }
}}>{p.customerName} 様
{p.date}
{p.name}
{p.attachment && (
{p.attachment}
)}
))}
)}
)}
{/* — タブ: 訪問履歴 (全顧客横断) — */}
{activeTab === ‘history’ && (
活動ログ
これまでの訪問記録を時系列で一覧表示しています
{allHistory.length === 0 ? (
訪問履歴はありません
) : (
{allHistory.map(h => (
{
const cust = customers.find(c => c.id === h.customerId);
if(cust){ setSelectedCustomer(cust); setView(‘detail’); setActiveTab(‘customers’); }
}}>{h.customerName} 様
{h.date}
{h.note}
))}
)}
)}
{/* — 顧客詳細画面 (案件・履歴の投稿UI含む) — */}
{activeTab === ‘customers’ && view === ‘detail’ && selectedCustomer && (
{/* クイックアクション */}
電話
{/* 基本情報 */}
{selectedCustomer.name} 様
ランク: {selectedCustomer.rank}
築{calculateAge(selectedCustomer.buildDate)}
Address
〒{selectedCustomer.zipCode}
{selectedCustomer.address}
Contact
{selectedCustomer.phone}
担当者
{selectedCustomer.manager}
{/* 案件情報 (投稿セクション) */}
案件情報
{/* 案件投稿フォーム */}
{/* 案件リスト */}
{selectedCustomer.projects && selectedCustomer.projects.length > 0 ? selectedCustomer.projects.map((p) => (
{p.name}
{p.date}
{p.attachment && (
{p.attachment}
)}
)) : (
登録された案件はありません
)}
{/* 訪問履歴 (投稿セクション) */}
訪問履歴
{/* 履歴投稿フォーム */}
{/* 履歴リスト */}
{selectedCustomer.history && selectedCustomer.history.length > 0 ? selectedCustomer.history.map((h) => (
)) : (
登録された訪問履歴はありません
)}
)}
{/* — 編集・新規作成フォーム — */}
{activeTab === ‘customers’ && view === ‘form’ && editingCustomer && (
{editingCustomer.id.length > 10 ? ‘新規顧客登録’ : ‘顧客情報の編集’}
)}
{/* AIモーダル */}
{aiModalOpen && (
AI 提案アシスタント
{isAiLoading ? (
) : aiResponse}
)}
);
};
export default App;