mitai-jinkendo/frontend/src/pages/AdminPanel.jsx
Lars 18991025bf
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
feat: add AdminCouponsPage for coupon management
Full CRUD interface for coupons:
- Create, edit, delete coupons
- Three coupon types supported:
  - Single-Use: one-time redemption per user
  - Multi-Use Period: unlimited redemptions in timeframe (Wellpass)
  - Gift: bonus system coupons

Features:
- Auto-generate random coupon codes
- Configure tier, duration, validity period
- Set max redemptions (or unlimited)
- View redemption history per coupon (modal)
- Active/inactive state management
- Card-based layout with visual type indicators

Form improvements:
- Conditional fields based on coupon type
- Date pickers for period coupons
- Duration config for single-use/gift
- Help text for each field
- Labels above inputs (consistent with other pages)

Integrated in AdminPanel navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:53:47 +01:00

436 lines
19 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({
ai_enabled: profile.ai_enabled ?? 1,
ai_limit_day: profile.ai_limit_day || '',
export_enabled: profile.export_enabled ?? 1,
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, {
ai_enabled: perms.ai_enabled,
ai_limit_day: perms.ai_limit_day ? parseInt(perms.ai_limit_day) : null,
export_enabled: perms.export_enabled,
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)'}}>
KI: {profile.ai_enabled?`${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
Export: {profile.export_enabled?'✓':'✗'} ·
Calls heute: {profile.ai_calls_today||0}
</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>
</div>
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/>
{!!perms.ai_enabled && (
<div className="form-row" style={{paddingTop:6}}>
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label>
<input type="number" className="form-input" style={{width:70}} min={1} max={100}
placeholder="∞" value={perms.ai_limit_day}
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/>
<span className="form-unit" style={{fontSize:11}}>/Tag</span>
</div>
)}
<Toggle value={!!perms.export_enabled} onChange={v=>setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/>
<button className="btn btn-primary btn-full" style={{marginTop:10}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Berechtigungen speichern'}
</button>
{/* 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>
</div>
</div>
</div>
)
}