mitai-jinkendo/frontend/src/pages/AdminPanel.jsx
Lars c8cf375399
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: AI-Prompts flexibilisierung - Frontend complete (Issue #28, Part 2)
Frontend components:
- PromptEditModal.jsx: Full editor with preview, generator, optimizer
- PromptGenerator.jsx: KI-assisted prompt creation from goal description
- Extended api.js with 10 new prompt endpoints

Navigation:
- Added /admin/prompts route to App.jsx
- Added KI-Prompts section to AdminPanel with navigation button

Features complete:
 Admin can create/edit/delete/duplicate prompts
 Category filtering and reordering
 Preview prompts with real user data
 KI generates prompts from goal + example data
 KI analyzes and optimizes existing prompts
 Side-by-side comparison original vs optimized

Ready for testing: http://dev.mitai.jinkendo.de/admin/prompts

Issue #28 Phase 2 complete - 13-18h estimated, ~14h actual

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:35:55 +01:00

474 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { api } from '../utils/api'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
function Avatar({ profile, size=36 }) {
const initials = profile.name.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2)
return (
<div style={{width:size,height:size,borderRadius:'50%',background:profile.avatar_color||'#1D9E75',
display:'flex',alignItems:'center',justifyContent:'center',
fontSize:size*0.35,fontWeight:700,color:'white',flexShrink:0}}>
{initials}
</div>
)
}
function Toggle({ value, onChange, label, disabled=false }) {
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
padding:'8px 0',borderBottom:'1px solid var(--border)'}}>
<span style={{fontSize:13,color:disabled?'var(--text3)':'var(--text1)'}}>{label}</span>
<div onClick={()=>!disabled&&onChange(!value)}
style={{width:40,height:22,borderRadius:11,background:value?'var(--accent)':'var(--border)',
position:'relative',cursor:disabled?'not-allowed':'pointer',transition:'background 0.2s',
opacity:disabled?0.5:1}}>
<div style={{position:'absolute',top:2,left:value?18:2,width:18,height:18,
borderRadius:'50%',background:'white',transition:'left 0.2s',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)'}}/>
</div>
</div>
)
}
function NewProfileForm({ onSave, onCancel }) {
const [form, setForm] = useState({
name:'', pin:'', email:'', avatar_color:COLORS[0],
sex:'m', height:'', auth_type:'pin', session_days:30
})
const [error, setError] = useState(null)
const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSave = async () => {
if (!form.name.trim()) return setError('Name eingeben')
if (form.pin.length < 4) return setError('PIN mind. 4 Zeichen')
try {
await onSave({...form, height:parseFloat(form.height)||178})
} catch(e) { setError(e.message) }
}
return (
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,
border:'1.5px solid var(--accent)',marginBottom:12}}>
<div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>Neues Profil</div>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={form.name} onChange={e=>set('name',e.target.value)} autoFocus/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:10}}>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>Farbe</div>
<div style={{display:'flex',gap:6}}>
{COLORS.map(c=>(
<div key={c} onClick={()=>set('avatar_color',c)}
style={{width:24,height:24,borderRadius:'50%',background:c,cursor:'pointer',
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
))}
</div>
</div>
<div className="form-row">
<label className="form-label">Geschlecht</label>
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
<option value="m">Männlich</option>
<option value="f">Weiblich</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Größe</label>
<input type="number" className="form-input" placeholder="178" value={form.height} onChange={e=>set('height',e.target.value)}/>
<span className="form-unit">cm</span>
</div>
<div className="form-row">
<label className="form-label">E-Mail</label>
<input type="email" className="form-input" placeholder="optional, für Recovery"
value={form.email} onChange={e=>set('email',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Login</label>
<select className="form-select" value={form.auth_type} onChange={e=>set('auth_type',e.target.value)}>
<option value="pin">PIN</option>
<option value="password">Passwort</option>
</select>
</div>
<div className="form-row">
<label className="form-label">PIN/Passwort</label>
<input type="password" className="form-input" placeholder="Mind. 4 Zeichen"
value={form.pin} onChange={e=>set('pin',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Session</label>
<select className="form-select" value={form.session_days} onChange={e=>set('session_days',parseInt(e.target.value))}>
<option value={7}>7 Tage</option>
<option value={30}>30 Tage</option>
<option value={0}>Immer</option>
</select>
</div>
{error && <div style={{color:'#D85A30',fontSize:12,marginBottom:8}}>{error}</div>}
<div style={{display:'flex',gap:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={handleSave}><Check size={13}/> Erstellen</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>
</div>
</div>
)
}
function EmailEditor({ profileId, currentEmail, onSaved }) {
const [email, setEmail] = useState(currentEmail||'')
const [msg, setMsg] = useState(null)
const save = async () => {
const token = localStorage.getItem('bodytrack_token')||''
await fetch(`/api/admin/profiles/${profileId}/email`, {
method:'PUT', headers:{'Content-Type':'application/json','X-Auth-Token':token},
body: JSON.stringify({email})
})
setMsg('✓ Gespeichert'); onSaved()
setTimeout(()=>setMsg(null),2000)
}
return (
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="email" className="form-input" placeholder="email@beispiel.de"
value={email} onChange={e=>setEmail(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={save}>Setzen</button>
{msg && <span style={{fontSize:11,color:'var(--accent)'}}>{msg}</span>}
</div>
)
}
function ProfileCard({ profile, currentId, onRefresh }) {
const [expanded, setExpanded] = useState(false)
const [perms, setPerms] = useState({
role: profile.role || 'user',
})
const [saving, setSaving] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const isSelf = profile.id === currentId
const savePerms = async () => {
setSaving(true)
try {
await api.adminSetPermissions(profile.id, {
role: perms.role,
})
await onRefresh()
} finally { setSaving(false) }
}
const savePin = async () => {
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
try {
await fetch(`/api/admin/profiles/${profile.id}/pin`, {
method:'PUT', headers:{'Content-Type':'application/json',
'X-Auth-Token': localStorage.getItem('bodytrack_token')||''},
body: JSON.stringify({pin: newPin})
})
setNewPin(''); setPinMsg('✓ PIN geändert')
setTimeout(()=>setPinMsg(null),2000)
} catch(e) { setPinMsg('Fehler: '+e.message) }
}
const deleteProfile = async () => {
if (!confirm(`Profil "${profile.name}" und ALLE Daten löschen?`)) return
await api.adminDeleteProfile(profile.id)
await onRefresh()
}
return (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',gap:10}}>
<Avatar profile={profile}/>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:6}}>
{profile.name}
{profile.role==='admin' && <span style={{fontSize:10,color:'var(--accent)',background:'var(--accent-light)',padding:'1px 5px',borderRadius:4}}>👑 Admin</span>}
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
Tier: {profile.tier || 'free'} ·
Email: {profile.email || 'nicht gesetzt'}
</div>
</div>
<div style={{display:'flex',gap:6}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setExpanded(e=>!e)}>
<Pencil size={12}/>
</button>
{!isSelf && (
<button className="btn btn-danger" style={{padding:'5px 8px'}}
onClick={deleteProfile}>
<Trash2 size={12}/>
</button>
)}
</div>
</div>
{expanded && (
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
{/* Permissions */}
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div>
<div style={{marginBottom:8}}>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:4}}>Rolle</div>
<div style={{display:'flex',gap:6}}>
{['user','admin'].map(r=>(
<button key={r} onClick={()=>setPerms(p=>({...p,role:r}))}
style={{flex:1,padding:'6px',borderRadius:8,border:`1.5px solid ${perms.role===r?'var(--accent)':'var(--border2)'}`,
background:perms.role===r?'var(--accent-light)':'var(--surface)',
color:perms.role===r?'var(--accent-dark)':'var(--text2)',
fontFamily:'var(--font)',fontSize:13,cursor:'pointer'}}>
{r==='admin'?'👑 Admin':'👤 Nutzer'}
</button>
))}
</div>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Rolle speichern'}
</button>
</div>
{/* Feature-Overrides */}
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
<strong>Feature-Limits:</strong> Nutze die neue{' '}
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
User Feature-Overrides
</Link>{' '}
Seite um individuelle Limits zu setzen.
</div>
{/* Email */}
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>E-MAIL (für Recovery & Zusammenfassungen)</div>
<EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/>
</div>
{/* PIN change */}
<div style={{marginTop:14,paddingTop:12,borderTop:'1px solid var(--border)'}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8,display:'flex',alignItems:'center',gap:4}}>
<Key size={12}/> PIN / PASSWORT ÄNDERN
</div>
<div style={{display:'flex',gap:8}}>
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={savePin}>Setzen</button>
</div>
{pinMsg && <div style={{fontSize:11,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:4}}>{pinMsg}</div>}
</div>
</div>
)}
</div>
)
}
function EmailSettings() {
const [status, setStatus] = useState(null)
const [testTo, setTestTo] = useState('')
const [testing, setTesting] = useState(false)
const [testMsg, setTestMsg] = useState(null)
useEffect(()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
.then(r=>r.json()).then(setStatus)
},[])
const sendTest = async () => {
if (!testTo) return
setTesting(true); setTestMsg(null)
try {
const token = localStorage.getItem('bodytrack_token')||''
const r = await fetch('/api/admin/email/test',{
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({to:testTo})
})
if(!r.ok) throw new Error((await r.json()).detail)
setTestMsg('✓ Test-E-Mail gesendet!')
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
finally{ setTesting(false) }
}
return (
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
📧 E-Mail Konfiguration
</div>
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
<>
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
{status.configured
? <> Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
: <> Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
</div>
{status.configured && (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
<strong>App-URL:</strong> {status.app_url}<br/>
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
</div>
<div style={{display:'flex',gap:8}}>
<input type="email" className="form-input" placeholder="test@beispiel.de"
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
{testing?'…':'Test'}
</button>
</div>
{testMsg && <div style={{fontSize:12,marginTop:6,
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
</>
)}
{!status.configured && (
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
display:'block',marginTop:6,fontSize:11}}>
SMTP_HOST=smtp.gmail.com<br/>
SMTP_PORT=587<br/>
SMTP_USER=deine@gmail.com<br/>
SMTP_PASS=dein_app_passwort<br/>
APP_URL=http://192.168.2.49:3002
</code>
</div>
)}
</>
)}
</div>
)
}
export default function AdminPanel() {
const { session } = useAuth()
const [profiles, setProfiles] = useState([])
const [creating, setCreating] = useState(false)
const [loading, setLoading] = useState(true)
const load = () => api.adminListProfiles().then(data=>{ setProfiles(data); setLoading(false) })
useEffect(()=>{ load() },[])
const handleCreate = async (form) => {
await api.adminCreateProfile(form)
setCreating(false)
await load()
}
if (loading) return <div className="empty-state"><div className="spinner"/></div>
return (
<div>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:16}}>
<Shield size={18} color="var(--accent)"/>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>Benutzerverwaltung</h2>
</div>
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}>
👑 Du bist Admin. Hier kannst du Profile verwalten, Berechtigungen setzen und KI-Limits konfigurieren.
</div>
{creating && (
<NewProfileForm onSave={handleCreate} onCancel={()=>setCreating(false)}/>
)}
{profiles.map(p=>(
<ProfileCard key={p.id} profile={p} currentId={session?.profile_id} onRefresh={load}/>
))}
{!creating && (
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
onClick={()=>setCreating(true)}>
<Plus size={14}/> Neues Profil anlegen
</button>
)}
{/* Email Settings */}
<EmailSettings/>
{/* v9c Subscription Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Tiers, Features und Limits für das neue Freemium-System.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/tiers">
<button className="btn btn-secondary btn-full">
🎯 Tiers verwalten
</button>
</Link>
<Link to="/admin/features">
<button className="btn btn-secondary btn-full">
🔧 Feature-Registry verwalten
</button>
</Link>
<Link to="/admin/tier-limits">
<button className="btn btn-secondary btn-full">
📊 Tier Limits Matrix bearbeiten
</button>
</Link>
<Link to="/admin/coupons">
<button className="btn btn-secondary btn-full">
🎟 Coupons verwalten
</button>
</Link>
<Link to="/admin/user-restrictions">
<button className="btn btn-secondary btn-full">
👤 User Feature-Overrides
</button>
</Link>
</div>
</div>
{/* v9d Training Types Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/training-types">
<button className="btn btn-secondary btn-full">
🏋 Trainingstypen verwalten
</button>
</Link>
<Link to="/admin/activity-mappings">
<button className="btn btn-secondary btn-full">
🔗 Activity-Mappings (lernendes System)
</button>
</Link>
<Link to="/admin/training-profiles">
<button className="btn btn-secondary btn-full">
Training Type Profiles (#15)
</button>
</Link>
</div>
</div>
{/* KI-Prompts Section */}
<div className="card">
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> KI-Prompts (v9f)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte AI-Prompts mit KI-Unterstützung: Generiere, optimiere und organisiere Prompts.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/prompts">
<button className="btn btn-secondary btn-full">
🤖 KI-Prompts verwalten
</button>
</Link>
</div>
</div>
</div>
)
}