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>
436 lines
19 KiB
JavaScript
436 lines
19 KiB
JavaScript
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>
|
||
)
|
||
}
|