// --- 依存関係のフォールバック (プレビュー環境でのクラッシュ回避用) --- if (typeof window !== 'undefined') { if (!window.React) { window.React = { useState: () => [null, ()=>{}], useEffect: ()=>{}, useMemo: (f)=>f() }; } if (!window.Icons) { window.Icons = new Proxy({}, { get: () => function DummyIcon(props) { return window.React.createElement('span', {style:{display:'inline-block', width:'1em', height:'1em', backgroundColor:'#4b5563', borderRadius:'50%'}}, ''); } }); } if (!window.getCourseDisplay) window.getCourseDisplay = c => c === '30min' ? '30分' : (c === '60min' ? '60分' : c); if (!window.getCourseShortDisplay) window.getCourseShortDisplay = c => c === '30min' ? '30分' : (c === '60min' ? '60分' : c); if (!window.fetchApi) window.fetchApi = async () => ({ success: true }); } const { useState, useEffect, useMemo } = window.React; const Icons = window.Icons; const getCourseDisplay = window.getCourseDisplay; const getCourseShortDisplay = window.getCourseShortDisplay; const fetchApi = window.fetchApi; // --- ユーティリティ --- const checkIsAdmin = (user) => { return user && (String(user.is_admin) === '1' || user.is_admin === true); }; const CONSULTATION_METHODS = ['電話', 'LINE', 'Zoom', 'Google Meet', 'Skype', 'FaceTime', 'Teams']; // --- トースト通知コンポーネント (UI改善) --- const Toast = ({ message, type = 'success', onClose }) => { useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]); return (
{type === 'success' ? : } {message}
); }; // --- 各種管理コンポーネント --- const TellerForm = ({ initialData, onSave, onClose, isAdmin, showToast }) => { const [activeTab, setActiveTab] = useState('basic'); const defaultForm = { name: '', role: '', description: '', image_url: '', personal_url: '', twitter_url: '', instagram_url: '', work_start_time: '10:00', work_end_time: '19:00', userid: '', password: '', email: '', is_admin: 0, is_visible: 1, available_methods: '', connection_metadata: '{}', real_name: '', kana_name: '', private_birth_date: '', zip_code: '', address: '', note: '', color: 'purple', price_30min: 5000, price_60min: 9000, price_extend10min: 1500, commission_rate: 60 }; const [form, setForm] = useState(() => initialData ? { ...defaultForm, ...initialData, password: '' } : defaultForm); const [selectedMethods, setSelectedMethods] = useState([]); const [connectData, setConnectData] = useState({}); useEffect(() => { if (form.available_methods) setSelectedMethods(form.available_methods.split(',').filter(Boolean)); if (form.connection_metadata) { try { setConnectData(typeof form.connection_metadata === 'string' ? JSON.parse(form.connection_metadata) : form.connection_metadata); } catch(e) { setConnectData({}); } } }, []); const handleMethodToggle = (method) => { let newMethods = selectedMethods.includes(method) ? selectedMethods.filter(m => m !== method) : [...selectedMethods, method]; setSelectedMethods(newMethods); setForm(prev => ({ ...prev, available_methods: newMethods.join(',') })); }; const handleConnectDataChange = (method, value) => { const newData = { ...connectData, [method]: value }; setConnectData(newData); setForm(prev => ({ ...prev, connection_metadata: JSON.stringify(newData) })); }; const handleImageChange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onloadend = () => setForm({...form, image_url: reader.result}); reader.readAsDataURL(file); } }; const themeColors = [ { id: 'purple', class: 'bg-purple-600', shadow: 'shadow-purple-500/50' }, { id: 'blue', class: 'bg-blue-600', shadow: 'shadow-blue-500/50' }, { id: 'red', class: 'bg-red-600', shadow: 'shadow-red-500/50' }, { id: 'green', class: 'bg-green-600', shadow: 'shadow-green-500/50' }, { id: 'gray', class: 'bg-gray-600', shadow: 'shadow-gray-500/50' }, ]; const inputClass = "w-full bg-gray-900/80 border border-gray-700 rounded-xl p-3 text-white focus:border-yellow-500 focus:bg-gray-900 focus:ring-2 focus:ring-yellow-500/20 outline-none transition-all duration-300 placeholder-gray-600"; const labelClass = "text-xs text-gray-400 font-bold mb-1.5 block tracking-widest"; return (

{initialData ? 'プロフィール編集' : '新規鑑定師の登録'}

{initialData ? `ID: ${form.userid}` : 'New Profile'}
{['basic', 'methods', ...(isAdmin ? ['internal'] : [])].map(tab => ( ))}
{activeTab === 'basic' && (
{isAdmin && ( )}
{form.image_url ? : }

プロフィール画像

setForm({...form, name: e.target.value})} placeholder="例: 阿弥 (Ami)" />
setForm({...form, role: e.target.value})} placeholder="例: タロット占い師" />
setForm({...form, price_30min: parseInt(e.target.value) || 0})} />
setForm({...form, price_60min: parseInt(e.target.value) || 0})} />
setForm({...form, price_extend10min: parseInt(e.target.value) || 0})} />
{themeColors.map(color => (

外部リンク

setForm({...form, personal_url: e.target.value})} placeholder="https://..." />
setForm({...form, twitter_url: e.target.value})} placeholder="https://twitter.com/..." />
setForm({...form, instagram_url: e.target.value})} placeholder="https://instagram.com/..." />

システム設定

setForm({...form, userid: e.target.value})} />
setForm({...form, password: e.target.value})} placeholder={initialData ? "********" : "パスワードを入力"} />
setForm({...form, email: e.target.value})} placeholder="通知を受け取るアドレス" />

稼働時間 (予約枠の生成基準)

setForm({...form, work_start_time: e.target.value})} /> setForm({...form, work_end_time: e.target.value})} />
)} {activeTab === 'methods' && (

お客様との対話方法を設定します。

チェックを入れた項目が、お客様の予約画面で選択可能になります。各ツールで使用する「あなたのIDやURL」を入力しておくと、支払いが完了したお客様へ自動で送信されます。
{['電話', 'LINE', 'Zoom', 'Google Meet', 'Skype', 'FaceTime', 'Teams'].map(m => (
{selectedMethods.includes(m) && (
handleConnectDataChange(m, e.target.value)} />
)}
))}
)} {activeTab === 'internal' && isAdmin && (

管理者専用の機密情報です。

このタブに入力された情報はサイト上に公開されず、業務連絡用の情報として厳重に管理されます。
setForm({...form, real_name: e.target.value})} placeholder="山田 花子" />
setForm({...form, kana_name: e.target.value})} placeholder="ヤマダ ハナコ" />
setForm({...form, private_birth_date: e.target.value})} />
setForm({...form, address: e.target.value})} placeholder="東京都..." />

報酬(歩合)設定

setForm({...form, commission_rate: parseInt(e.target.value) || 0})} placeholder="60" />%
)}
{/* 【改善】保存ボタン領域を最下部にSticky固定 */}
); }; const AdminTellers = ({ tellers, onEdit, onToggleVisibility, onAddNew }) => (

鑑定師管理

{tellers.map(t => (
{t.image_url ? ( {t.name} ) : (
)}

{t.name}

{t.role}

{t.description}
))}
); // 【復活させたBookingModalコンポーネント】 const AdminBookingModal = ({ booking, teller, onClose, onUpdateStatus, onRequestExtension }) => { if (!booking) return null; const extendPrice = teller?.price_extend10min || 1500; return (

予約詳細

予約日時
{booking.booking_date}
お客様情報
{booking.user_name} {booking.user_email}
コースと料金
{getCourseDisplay(booking.course)} ¥{booking.final_price?.toLocaleString()}
ステータス変更
{[{ id: 'pending', label: '未払い', c: 'hover:bg-yellow-900/50 hover:border-yellow-600 text-yellow-500', active: 'bg-yellow-600 text-white border-yellow-500 shadow-[0_0_15px_rgba(202,138,4,0.3)]' }, { id: 'confirmed', label: '確定済', c: 'hover:bg-green-900/50 hover:border-green-600 text-green-500', active: 'bg-green-600 text-white border-green-500 shadow-[0_0_15px_rgba(22,163,74,0.3)]' }, { id: 'completed', label: '完了', c: 'hover:bg-blue-900/50 hover:border-blue-600 text-blue-500', active: 'bg-blue-600 text-white border-blue-500 shadow-[0_0_15px_rgba(37,99,235,0.3)]' }, { id: 'cancelled', label: 'キャンセル', c: 'hover:bg-gray-700 hover:border-gray-500 text-gray-400', active: 'bg-gray-700 text-white border-gray-500' } ].map(s => ( ))}
{booking.status === 'confirmed' && !booking.extension_status && (
オプション操作
)} {booking.extension_status === 'pending' && (
延長料金 (¥{booking.extension_fee?.toLocaleString() || extendPrice.toLocaleString()}) のお客様支払い待ち
)} {booking.extension_status === 'paid' && (
10分延長 追加済み (支払い完了)
)}
); }; const AdminScheduleTable = ({ selectedTeller, bookings, onBookingClick, onBookingDrop, onToggleHoliday, showToast }) => { if (!selectedTeller) return
占い師を選択してください
; const startHour = 8; const endHour = 24; const totalHours = endHour - startHour; const hourWidth = 60; const hours = Array.from({ length: totalHours + 1 }, (_, i) => startHour + i); const dates = Array.from({ length: 14 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() + i); return { full: d.toISOString().split('T')[0], display: `${d.getMonth() + 1}/${d.getDate()}` }; }); const [dragOverCell, setDragOverCell] = useState(null); const handleDragStart = (e, bookingId) => { e.dataTransfer.setData('bookingId', bookingId); e.dataTransfer.effectAllowed = 'move'; setTimeout(() => e.target.style.opacity = '0.5', 0); }; const handleDragEnd = (e) => { e.target.style.opacity = '1'; setDragOverCell(null); }; const handleDragOver = (e, dateStr) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const minutesFromStart = (x / hourWidth) * 60; const totalMinutes = Math.floor(minutesFromStart / 30) * 30; // 30分単位に丸める setDragOverCell(`${dateStr}_${totalMinutes}`); }; const handleDragLeave = () => setDragOverCell(null); const handleDrop = (e, targetDate) => { e.preventDefault(); setDragOverCell(null); const bookingId = e.dataTransfer.getData('bookingId'); if (!bookingId) return; const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const minutesFromStart = (x / hourWidth) * 60; const totalMinutes = Math.round(minutesFromStart / 30) * 30; let newH = startHour + Math.floor(totalMinutes / 60); let newM = totalMinutes % 60; if (newH < startHour) { newH = startHour; newM = 0; } if (newH >= endHour) { newH = endHour - 1; newM = 30; } const timeStr = `${String(newH).padStart(2, '0')}:${String(newM).padStart(2, '0')}`; onBookingDrop(bookingId, targetDate, timeStr); }; return (
日付
{hours.map(h =>
{h}:00
)}
{dates.map(dateItem => { const dayBookings = bookings.filter(b => b.fortune_teller_id == selectedTeller.id && (b.booking_date || '').startsWith(dateItem.full) && b.status !== 'cancelled'); const isHoliday = dayBookings.some(b => b.status === 'holiday'); return (
{dateItem.display}
handleDragOver(e, dateItem.full)} onDragLeave={handleDragLeave} onDrop={isHoliday ? undefined : (e) => handleDrop(e, dateItem.full)}> {isHoliday &&
HOLIDAY
} {!isHoliday && Array.from({ length: totalHours * 2 }).map((_, i) => { const isOver = dragOverCell === `${dateItem.full}_${i * 30}`; return (
) })} {!isHoliday && dayBookings.map(b => { if (b.status === 'holiday') return null; const [h,m] = (b.booking_date || '00:00 10:00').split(' ')[1].split(':').map(Number); const left = ((h - startHour) * 60 + m) / 60 * hourWidth; const colorClass = b.status === 'confirmed' ? 'bg-gradient-to-r from-green-600 to-green-500 border-green-400' : b.status === 'completed' ? 'bg-gradient-to-r from-blue-700 to-blue-600 border-blue-500 opacity-60' : 'bg-gradient-to-r from-yellow-600 to-yellow-500 border-yellow-400'; const isExtended = b.extension_status === 'paid' || b.extension_status === 'pending'; const widthClass = (b.course && b.course.includes('60')) ? '58px' : (isExtended ? '38px' : '28px'); return (
handleDragStart(e, b.id)} onDragEnd={handleDragEnd} onClick={() => onBookingClick(b)} className={`absolute top-2 bottom-2 rounded-md cursor-grab border flex flex-col justify-center px-1 text-[10px] text-white overflow-hidden shadow-lg hover:shadow-xl hover:brightness-110 active:scale-95 transition-all z-10 ${colorClass} ${b.extension_status === 'pending' ? 'animate-pulse' : ''}`} style={{ left: `${left}px`, width: widthClass }} title={`${b.user_name} (${getCourseDisplay(b.course)})`}>
{b.user_name}
{getCourseShortDisplay(b.course)}{isExtended ? '+10m' : ''}
); })}
); })}
未払い 確定 完了 ※予約枠をドラッグ&ドロップで時間変更できます
); }; const AdminAnalytics = ({ tellers, bookings, authUser, isAdmin }) => { const [targetMonth, setTargetMonth] = useState(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }); const stats = useMemo(() => { let targetTellers = tellers.filter(t => !checkIsAdmin(t)); if (!isAdmin) targetTellers = targetTellers.filter(t => t.id === authUser.id); const data = targetTellers.map(t => ({ ...t, totalSales: 0, count: 0, commissionAmount: 0 })); let total = 0; let totalCommission = 0; bookings.forEach(b => { if (b.status === 'cancelled' || b.status === 'holiday' || b.status === 'pending') return; if (!(b.booking_date || '').startsWith(targetMonth)) return; const t = data.find(dt => dt.id == b.fortune_teller_id); if (t) { const price = parseInt(b.final_price || 0) + parseInt(b.extension_fee || 0); t.totalSales += price; total += price; t.count++; } }); data.forEach(t => { const rate = parseInt(t.commission_rate) || 60; t.commissionAmount = Math.floor(t.totalSales * (rate / 100)); totalCommission += t.commissionAmount; }); return { tellers: data.sort((a,b)=>b.totalSales-a.totalSales), total, totalCommission }; }, [tellers, bookings, authUser, isAdmin, targetMonth]); return (

{isAdmin ? '売上・報酬分析' : '今月の報酬明細'}

setTargetMonth(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg p-2 text-white text-sm outline-none focus:border-yellow-500" />

{isAdmin ? '総売上' : 'あなたの総売上'}

¥{stats.total.toLocaleString()}

{isAdmin ? '占い師への支払報酬額合計' : '今月の獲得報酬 (見込み)'}

¥{stats.totalCommission.toLocaleString()}

詳細データ ({targetMonth})

{isAdmin && } {stats.tellers.map(t => ( {isAdmin && } ))} {stats.tellers.length === 0 && }
鑑定師件数売上金額歩合率報酬額 (支払額)本部利益
{t.image_url ? :
}{t.name}
{t.count}件 ¥{t.totalSales.toLocaleString()} {t.commission_rate}% ¥{t.commissionAmount.toLocaleString()}¥{(t.totalSales - t.commissionAmount).toLocaleString()}
この月のデータはありません
); }; const AdminCustomers = ({ showToast }) => { const [customers, setCustomers] = useState([]); const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { fetchApi('customer_stats').then(setCustomers); }, []); const toggleBl = async (email, current) => { await fetchApi('toggle_blacklist', { method: 'POST', body: JSON.stringify({ email, is_blacklisted: current ? 0 : 1 }) }); fetchApi('customer_stats').then(setCustomers); showToast(current ? 'ブラックリストから解除しました' : 'ブラックリストに登録しました', current ? 'success' : 'error'); }; const filteredCustomers = customers.filter(c => (c.user_name && c.user_name.includes(searchQuery)) || (c.user_email && c.user_email.includes(searchQuery)) ); return (

顧客管理

setSearchQuery(e.target.value)} placeholder="名前かメールで検索..." className="w-full bg-gray-950 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-sm text-white focus:border-red-400 outline-none transition-colors" />
{filteredCustomers.map(c => { const isBl = parseInt(c.is_blacklisted) === 1; return ( ) })} {filteredCustomers.length === 0 && }
顧客名 / メール総予約キャンセル操作
{c.user_name}
{c.user_email}
{isBl && BL登録中}
{c.total_bookings} {c.cancel_count}
該当する顧客が見つかりません
); }; // 【復元】クーポン管理コンポーネント const AdminCoupons = ({ showToast }) => { const [coupons, setCoupons] = useState([]); const [editingCoupon, setEditingCoupon] = useState(null); const loadData = () => fetchApi('get_coupons').then(setCoupons); useEffect(() => { loadData(); }, []); const handleSaveCoupon = async (e) => { e.preventDefault(); try { const res = await fetchApi('save_coupon', { method: 'POST', body: JSON.stringify(editingCoupon) }); if (res && res.success) { setEditingCoupon(null); loadData(); showToast('クーポンを保存しました'); } else { showToast(res?.message || '保存に失敗しました', 'error'); } } catch (error) { showToast('通信エラーが発生しました', 'error'); } }; const handleDeleteCoupon = async (id) => { if(!confirm('本当にこのクーポンを削除しますか?')) return; try { await fetchApi('delete_coupon', { method: 'POST', body: JSON.stringify({ id }) }); loadData(); showToast('クーポンを削除しました'); } catch(e) { showToast('エラーが発生しました', 'error'); } }; const handleToggleActive = async (coupon) => { const updatedCoupon = { ...coupon, is_active: parseInt(coupon.is_active) === 1 ? 0 : 1 }; try { await fetchApi('save_coupon', { method: 'POST', body: JSON.stringify(updatedCoupon) }); loadData(); showToast(updatedCoupon.is_active ? 'クーポンを有効にしました' : 'クーポンを無効にしました'); } catch (e) { showToast('エラーが発生しました', 'error'); } }; const inputClass = "w-full bg-gray-900 border border-gray-700 rounded-xl p-3 outline-none focus:border-yellow-500 focus:ring-2 focus:ring-yellow-500/20 text-white text-sm transition-all duration-300"; const btnPrimary = "bg-gradient-to-r from-yellow-600 to-yellow-500 hover:from-yellow-500 hover:to-yellow-400 text-gray-900 px-6 py-2.5 rounded-lg font-bold text-sm shadow-[0_0_15px_rgba(234,179,8,0.3)] transition-all active:scale-95 flex items-center gap-2"; const btnCancel = "bg-gray-700 hover:bg-gray-600 text-white px-6 py-2.5 rounded-lg font-bold text-sm transition-all duration-300 active:scale-95"; return (
{!editingCoupon ? (

クーポン一覧

{coupons.map(c => ( ))} {coupons.length === 0 && ( )}
コード説明割引有効期限状態操作
{c.code} {c.description} {c.discount_type === 'fixed' ? `¥${c.discount_value} OFF` : `${c.discount_value}% OFF`} {c.expiry_date ? c.expiry_date : '無期限'}
クーポンが登録されていません
) : (

{editingCoupon.id ? 'クーポンの編集' : 'クーポンの追加'}

setEditingCoupon({...editingCoupon, code: e.target.value.toUpperCase()})} placeholder="WELCOME2026" />
setEditingCoupon({...editingCoupon, description: e.target.value})} placeholder="初回限定割引など" />
setEditingCoupon({...editingCoupon, discount_value: parseInt(e.target.value) || 0})} />
setEditingCoupon({...editingCoupon, expiry_date: e.target.value})} />
)}
); }; const AdminContents = ({ tellers, authUser, isAdmin, showToast }) => { const [activeSubTab, setActiveSubTab] = useState('blogs'); const [news, setNews] = useState([]); const [testimonials, setTestimonials] = useState([]); const [faqs, setFaqs] = useState([]); const [blogs, setBlogs] = useState([]); const [editingNews, setEditingNews] = useState(null); const [editingTestimonial, setEditingTestimonial] = useState(null); const [editingFaq, setEditingFaq] = useState(null); const [editingBlog, setEditingBlog] = useState(null); const loadData = () => { if (isAdmin) { fetchApi('news_admin').then(setNews); fetchApi('testimonials_admin').then(setTestimonials); fetchApi('faqs_admin').then(setFaqs); } fetchApi('blogs_admin').then(setBlogs); }; useEffect(() => { loadData(); }, [isAdmin]); const handleSave = async (apiAction, data, setEditingFn, successMsg) => { try { await fetchApi(apiAction, { method: 'POST', body: JSON.stringify(data) }); setEditingFn(null); loadData(); showToast(successMsg); } catch(err) { showToast('保存に失敗しました', 'error'); } }; const handleDelete = async (apiAction, id, successMsg) => { if(!confirm('本当に削除しますか?')) return; try { await fetchApi(apiAction, { method: 'POST', body: JSON.stringify({ id }) }); loadData(); showToast(successMsg); } catch(err) { showToast('削除に失敗しました', 'error'); } }; const handleBlogImageChange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onloadend = () => setEditingBlog({...editingBlog, image_url: reader.result}); reader.readAsDataURL(file); } }; const inputClass = "w-full bg-gray-900 border border-gray-700 rounded-xl p-3 outline-none focus:border-yellow-500 focus:ring-2 focus:ring-yellow-500/20 text-white text-sm transition-all duration-300"; const btnPrimary = "bg-gradient-to-r from-yellow-600 to-yellow-500 hover:from-yellow-500 text-gray-900 px-6 py-2.5 rounded-lg font-bold text-sm shadow-[0_0_15px_rgba(234,179,8,0.3)] transition-all active:scale-95"; const btnCancel = "bg-gray-700 hover:bg-gray-600 text-white px-6 py-2.5 rounded-lg font-bold text-sm transition-all active:scale-95"; return (
{isAdmin && } {isAdmin && } {isAdmin && }
{activeSubTab === 'blogs' && (
{!editingBlog ? ( <>

執筆した記事一覧

{isAdmin && } {blogs.map(b => ( {isAdmin && } ))} {blogs.length === 0 && }
公開日タイトル執筆者公開操作
{b.published_date}
{b.title}
{b.category || '未分類'}
{b.author_name || '運営'}{parseInt(b.is_visible) ? : }
記事がありません
) : (
{ e.preventDefault(); handleSave('save_blog', editingBlog, setEditingBlog, '記事を保存しました'); }} className="flex flex-col h-full bg-gray-900/50">

{editingBlog.id ? '記事の編集' : '新規記事の作成'}

setEditingBlog({...editingBlog, title: e.target.value})} />
setEditingBlog({...editingBlog, published_date: e.target.value})} />
setEditingBlog({...editingBlog, category: e.target.value})} />
{isAdmin &&
}
{editingBlog.image_url && }
)}
)} {/* 【復元】お知らせ管理 */} {activeSubTab === 'news' && isAdmin && (
{!editingNews ? ( <>

お知らせ一覧

{news.map(n => ( ))} {news.length === 0 && }
日付 / タグ内容公開操作
{n.published_date} {n.tag}

{n.text}

{parseInt(n.is_visible) ? : }
お知らせがありません
) : (
{ e.preventDefault(); handleSave('save_news', editingNews, setEditingNews, 'お知らせを保存しました'); }} className="flex flex-col h-full bg-gray-900/50">

{editingNews.id ? 'お知らせの編集' : 'お知らせの追加'}

setEditingNews({...editingNews, published_date: e.target.value})} />
)}
)} {/* 【復元】お客様の声管理 */} {activeSubTab === 'testimonials' && isAdmin && (
{!editingTestimonial ? ( <>

お客様の声一覧

{testimonials.map(t => ( ))} {testimonials.length === 0 && }
お名前 / 年代対象鑑定師レビュー内容公開操作
{t.customer_name}
{t.age_group}
{t.teller_name}
{[...Array(t.rating||5)].map((_,i)=>)}

{t.testimonial_text}

{parseInt(t.is_visible) ? : }
レビューがありません
) : (
{ e.preventDefault(); handleSave('save_testimonial', editingTestimonial, setEditingTestimonial, 'レビューを保存しました'); }} className="flex flex-col h-full bg-gray-900/50">

{editingTestimonial.id ? 'お客様の声の編集' : 'お客様の声の追加'}

setEditingTestimonial({...editingTestimonial, customer_name: e.target.value})} />
setEditingTestimonial({...editingTestimonial, age_group: e.target.value})} placeholder="30代 女性" />
setEditingTestimonial({...editingTestimonial, rating: parseInt(e.target.value) || 5})} />
)}
)} {/* 【復元】FAQ管理 */} {activeSubTab === 'faqs' && isAdmin && (
{!editingFaq ? ( <>

よくある質問 (FAQ) 一覧

{faqs.map(f => ( ))} {faqs.length === 0 && }
順序質問 / 回答公開操作
{f.sort_order}
Q. {f.question}
A. {f.answer}
{parseInt(f.is_visible) ? : }
FAQがありません
) : (
{ e.preventDefault(); handleSave('save_faq', editingFaq, setEditingFaq, 'FAQを保存しました'); }} className="flex flex-col h-full bg-gray-900/50">

{editingFaq.id ? 'FAQの編集' : 'FAQの追加'}

setEditingFaq({...editingFaq, question: e.target.value})} />
setEditingFaq({...editingFaq, sort_order: parseInt(e.target.value) || 0})} />
)}
)}
); }; const AdminViewApp = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [authUser, setAuthUser] = useState(null); const [userid, setUserid] = useState('admin'); const [password, setPassword] = useState('pass'); const [activeTab, setActiveTab] = useState(() => { if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); return params.get('tab') || 'schedule'; } return 'schedule'; }); const [tellers, setTellers] = useState([]); const [bookings, setBookings] = useState([]); const [selectedTellerId, setSelectedTellerId] = useState(null); const [viewingBooking, setViewingBooking] = useState(null); const [showForm, setShowForm] = useState(false); const [editingTeller, setEditingTeller] = useState(null); // 【追加】Toast通知の状態 const [toast, setToast] = useState(null); const isAdmin = authUser && parseInt(authUser.is_admin) === 1; const showToast = (message, type = 'success') => setToast({ message, type, id: Date.now() }); const handleTabChange = (tab) => { setActiveTab(tab); if (typeof window !== 'undefined') { const url = new URL(window.location.href); url.searchParams.set('tab', tab); window.history.replaceState({}, '', url); } }; const fetchData = () => { fetchApi('fortune_tellers_admin').then(data => { setTellers(data); if(data.length > 0 && !selectedTellerId) { if (authUser && parseInt(authUser.is_admin) === 0) { setSelectedTellerId(authUser.id); } else { setSelectedTellerId(data[0].id); } } }); fetchApi('bookings_admin').then(setBookings); }; useEffect(() => { if (isLoggedIn && authUser) fetchData(); }, [isLoggedIn, authUser]); const handleLogin = async (e) => { e.preventDefault(); const res = await fetchApi('login', { method: 'POST', body: JSON.stringify({ userid, password }) }); if (res.success) { setAuthUser(res.user); if (parseInt(res.user.is_admin) === 0) { setSelectedTellerId(res.user.id); } setIsLoggedIn(true); showToast('ログインしました'); } else { showToast(res.message, 'error'); } }; const handleSaveTeller = async (data) => { const action = editingTeller ? 'update_teller' : 'create_teller'; const payload = editingTeller ? { ...data, id: editingTeller.id } : data; await fetchApi(action, { method: 'POST', body: JSON.stringify(payload) }); fetchData(); setShowForm(false); setEditingTeller(null); }; const toggleVisibility = async (id, current) => { const nextVal = parseInt(current) === 1 ? 0 : 1; await fetchApi('toggle_visibility', { method: 'POST', body: JSON.stringify({ id, is_visible: nextVal }) }); fetchData(); showToast(nextVal ? '公開にしました' : '非公開にしました'); }; if (!isLoggedIn) { return (
{toast && setToast(null)} />}

管理パネル

setUserid(e.target.value)} />
setPassword(e.target.value)} />
); } return (
{toast && setToast(null)} />}
管理パネル {isAdmin ? '管理者権限' : authUser.name}
{isAdmin && ( )} {!isAdmin && ( )} {isAdmin && }
{isAdmin && } {isAdmin && }
{activeTab === 'schedule' && isAdmin && (
表示する占い師を選択
)} {activeTab === 'schedule' ? (
t.id===selectedTellerId)} bookings={bookings} onBookingClick={setViewingBooking} showToast={showToast} onBookingDrop={(id, date, time) => { if (!confirm(`予約日時を ${date} ${time} に変更しますか?\n※お客様には自動で通知されませんので別途ご連絡ください。`)) return; fetchApi('update_booking_time', { method: 'POST', body: JSON.stringify({ id, booking_date: `${date} ${time}:00` }) }) .then(() => { fetchData(); showToast('予約日時を変更しました'); }); }} onToggleHoliday={(date) => { fetchApi('toggle_holiday', { method: 'POST', body: JSON.stringify({ teller_id: selectedTellerId, date }) }) .then(fetchData); }} />
) : (
{activeTab === 'analytics' &&
} {activeTab === 'customers' &&
} {activeTab === 'coupons' && isAdmin &&
} {activeTab === 'contents' &&
} {activeTab === 'tellers' && isAdmin && {setEditingTeller(t); setShowForm(true);}} onToggleVisibility={toggleVisibility} onAddNew={() => {setEditingTeller(null); setShowForm(true);}} />}
)}
{showForm && { setShowForm(false); setEditingTeller(null); }} isAdmin={isAdmin} showToast={showToast} />} t.id == viewingBooking.fortune_teller_id) : null} onClose={() => setViewingBooking(null)} onUpdateStatus={(id, st) => { fetchApi('booking_status', {method:'POST', body:JSON.stringify({id, status:st})}).then(() => { fetchData(); showToast('ステータスを更新しました'); }); setViewingBooking(null); }} onRequestExtension={(id, fee) => { fetchApi('request_extension', {method: 'POST', body: JSON.stringify({id, fee})}).then(() => { fetchData(); showToast('延長を許可しました。お客様の支払いを待ちます'); setViewingBooking(prev => ({...prev, extension_status: 'pending', extension_fee: fee})); }); }} />
); }; if (typeof window !== 'undefined') window.AdminView = AdminViewApp;