mitai-jinkendo/frontend/src/pages/AdminPromptsPage.jsx
Lars c9357d4c0e
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
feat: Import Dialog mit 3 Buttons (Ja/Nein/Abbrechen)
Ersetzt zwei aufeinanderfolgende confirm()-Dialoge durch einen
Custom Dialog mit drei klaren Optionen:

- "Ja, überschreiben" → bestehende Prompts aktualisieren
- "Nein, nur neue" → existierende überspringen
- "Abbrechen" → Import komplett abbrechen

UX-Verbesserung:
- Alle Optionen auf einen Blick sichtbar
- Kein Raten mehr was "OK" oder "Abbrechen" bedeutet
- Klare Beschreibungstexte unter jedem Button
- Vollbildschirm-Modal mit Overlay

Technisch:
- importDialogData State für Dialog-Daten
- handleImportChoice verarbeitet yes/no/cancel
- Custom Modal-JSX statt Browser confirm()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:57:48 +02:00

697 lines
22 KiB
JavaScript

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import UnifiedPromptModal from '../components/UnifiedPromptModal'
import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } from 'lucide-react'
/**
* Admin Prompts Page - Unified System (Issue #28 Phase 3)
*
* Manages both base and pipeline-type prompts in one interface.
*/
export default function AdminPromptsPage() {
const navigate = useNavigate()
const [prompts, setPrompts] = useState([])
const [filteredPrompts, setFilteredPrompts] = useState([])
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline' | 'workflow'
const [category, setCategory] = useState('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [editingPrompt, setEditingPrompt] = useState(null)
const [showNewPrompt, setShowNewPrompt] = useState(false)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState(null)
const [importDialogData, setImportDialogData] = useState(null) // {count, fileData, event}
const categories = [
{ id: 'all', label: 'Alle Kategorien' },
{ id: 'körper', label: 'Körper' },
{ id: 'ernährung', label: 'Ernährung' },
{ id: 'training', label: 'Training' },
{ id: 'schlaf', label: 'Schlaf' },
{ id: 'vitalwerte', label: 'Vitalwerte' },
{ id: 'ziele', label: 'Ziele' },
{ id: 'ganzheitlich', label: 'Ganzheitlich' },
{ id: 'pipeline', label: 'Pipeline' }
]
useEffect(() => {
loadPrompts()
}, [])
useEffect(() => {
let filtered = prompts
// Filter by type
if (typeFilter === 'base') {
filtered = filtered.filter(p => p.type === 'base')
} else if (typeFilter === 'pipeline') {
filtered = filtered.filter(p => p.type === 'pipeline')
} else if (typeFilter === 'workflow') {
filtered = filtered.filter(p => p.type === 'workflow')
}
// Filter by category
if (category !== 'all') {
filtered = filtered.filter(p => p.category === category)
}
setFilteredPrompts(filtered)
}, [typeFilter, category, prompts])
const loadPrompts = async () => {
try {
setLoading(true)
const data = await api.listAdminPrompts()
setPrompts(data)
setError(null)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const handleToggleActive = async (prompt) => {
try {
await api.updateUnifiedPrompt(prompt.id, { active: !prompt.active })
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleDelete = async (prompt) => {
if (!confirm(`Prompt "${prompt.name}" wirklich löschen?`)) return
try {
await api.deletePrompt(prompt.id)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleDuplicate = async (prompt) => {
try {
await api.duplicatePrompt(prompt.id)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleConvertToBase = async (prompt) => {
// Convert a 1-stage pipeline to a base prompt
if (prompt.type !== 'pipeline') {
alert('Nur Pipeline-Prompts können konvertiert werden')
return
}
const stages = typeof prompt.stages === 'string'
? JSON.parse(prompt.stages)
: prompt.stages
if (!stages || stages.length !== 1) {
alert('Nur 1-stage Pipeline-Prompts können zu Basis-Prompts konvertiert werden')
return
}
const stage1 = stages[0]
if (!stage1.prompts || stage1.prompts.length !== 1) {
alert('Stage muss genau einen Prompt haben')
return
}
const firstPrompt = stage1.prompts[0]
if (firstPrompt.source !== 'inline' || !firstPrompt.template) {
alert('Nur inline Templates können konvertiert werden')
return
}
if (!confirm(`"${prompt.name}" zu Basis-Prompt konvertieren?`)) return
try {
await api.updateUnifiedPrompt(prompt.id, {
type: 'base',
template: firstPrompt.template,
output_format: firstPrompt.output_format || 'text',
stages: null
})
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleSave = async () => {
setEditingPrompt(null)
setShowNewPrompt(false)
await loadPrompts()
}
const getStageCount = (prompt) => {
if (prompt.type !== 'pipeline' || !prompt.stages) return 0
try {
const stages = typeof prompt.stages === 'string'
? JSON.parse(prompt.stages)
: prompt.stages
return stages.length
} catch (e) {
return 0
}
}
const getTypeLabel = (type) => {
if (type === 'base') return 'Basis'
if (type === 'pipeline') return 'Pipeline'
return type || 'Pipeline' // Default for old prompts
}
const getTypeColor = (type) => {
if (type === 'base') return 'var(--accent)'
if (type === 'pipeline') return '#6366f1'
return 'var(--text3)'
}
const handleExportAll = async () => {
try {
const data = await api.exportAllPrompts()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `all-prompts-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
setError('Export-Fehler: ' + e.message)
}
}
const handleImport = async (event) => {
const file = event.target.files[0]
if (!file) return
setError(null)
setImportResult(null)
try {
const text = await file.text()
const data = JSON.parse(text)
// Show custom 3-button dialog
setImportDialogData({
count: data.count || 0,
fileData: data,
event: event
})
} catch (e) {
setError('Import-Fehler: ' + e.message)
event.target.value = '' // Reset file input
}
}
const handleImportChoice = async (choice) => {
if (!importDialogData) return
const { fileData, event } = importDialogData
setImportDialogData(null) // Close dialog
if (choice === 'cancel') {
event.target.value = '' // Reset file input
return
}
setImporting(true)
try {
const overwrite = choice === 'yes' // 'yes' = overwrite, 'no' = skip existing
const result = await api.importPrompts(fileData, overwrite)
setImportResult(result)
await loadPrompts()
} catch (e) {
setError('Import-Fehler: ' + e.message)
} finally {
setImporting(false)
event.target.value = '' // Reset file input
}
}
return (
<div style={{
padding: 20,
maxWidth: 1400,
margin: '0 auto',
paddingBottom: 80
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24
}}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>
KI-Prompts ({filteredPrompts.length})
</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn"
onClick={handleExportAll}
title="Alle Prompts als JSON exportieren (Backup / Dev→Prod Sync)"
>
📦 Alle exportieren
</button>
<label className="btn" style={{ margin: 0, cursor: 'pointer' }}>
📥 Importieren
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={importing}
style={{ display: 'none' }}
/>
</label>
<button
className="btn btn-primary"
onClick={() => setShowNewPrompt(true)}
>
+ Neuer Prompt
</button>
<button
className="btn btn-secondary"
onClick={() => navigate('/workflow-editor/new')}
style={{ marginLeft: 8 }}
>
🔀 Neuer Workflow
</button>
</div>
</div>
{error && (
<div style={{
padding: 16,
background: '#fee',
color: '#c00',
borderRadius: 8,
marginBottom: 16
}}>
{error}
</div>
)}
{importResult && (
<div style={{
padding: 16,
background: '#efe',
color: '#060',
borderRadius: 8,
marginBottom: 16
}}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Import erfolgreich
</div>
<div style={{ fontSize: 13 }}>
{importResult.created} erstellt · {importResult.updated} aktualisiert · {importResult.skipped} übersprungen
</div>
<button
onClick={() => setImportResult(null)}
style={{ marginTop: 8, fontSize: 12, padding: '4px 8px' }}
className="btn"
>
OK
</button>
</div>
)}
{/* Filters */}
<div style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexWrap: 'wrap',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Filter size={16} color="var(--text3)" />
<span style={{ fontSize: 13, color: 'var(--text3)' }}>Typ:</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className={typeFilter === 'all' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('all')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Alle ({prompts.length})
</button>
<button
className={typeFilter === 'base' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('base')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Basis-Prompts ({prompts.filter(p => p.type === 'base').length})
</button>
<button
className={typeFilter === 'pipeline' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('pipeline')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
</button>
<button
className={typeFilter === 'workflow' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('workflow')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
🔀 Workflows ({prompts.filter(p => p.type === 'workflow').length})
</button>
</div>
<div style={{
width: 1,
height: 24,
background: 'var(--border)',
margin: '0 8px'
}} />
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
style={{ fontSize: 13, padding: '6px 12px' }}
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.label}
</option>
))}
</select>
</div>
{/* Prompts Table */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Lädt...
</div>
) : (
<div style={{
background: 'var(--surface)',
borderRadius: 12,
border: '1px solid var(--border)',
overflowX: 'auto'
}}>
<table style={{ width: '100%', minWidth: 900, borderCollapse: 'collapse' }}>
<thead>
<tr style={{
background: 'var(--surface2)',
borderBottom: '1px solid var(--border)'
}}>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 80
}}>
Typ
</th>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)'
}}>
Name
</th>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 120
}}>
Kategorie
</th>
<th style={{
padding: 12,
textAlign: 'center',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 100
}}>
Stages
</th>
<th style={{
padding: 12,
textAlign: 'center',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 80
}}>
Status
</th>
<th style={{
padding: 12,
textAlign: 'right',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 120
}}>
Aktionen
</th>
</tr>
</thead>
<tbody>
{filteredPrompts.length === 0 ? (
<tr>
<td colSpan="6" style={{
padding: 40,
textAlign: 'center',
color: 'var(--text3)'
}}>
Keine Prompts gefunden
</td>
</tr>
) : (
filteredPrompts.map(prompt => (
<tr
key={prompt.id}
style={{
borderBottom: '1px solid var(--border)',
opacity: prompt.active ? 1 : 0.5
}}
>
<td style={{ padding: 12 }}>
<div style={{
display: 'inline-block',
padding: '2px 8px',
background: getTypeColor(prompt.type) + '20',
color: getTypeColor(prompt.type),
borderRadius: 6,
fontSize: 11,
fontWeight: 600
}}>
{getTypeLabel(prompt.type)}
</div>
</td>
<td style={{ padding: 12 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>
{prompt.display_name || prompt.name}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{prompt.slug}
</div>
</div>
</td>
<td style={{ padding: 12, fontSize: 13 }}>
{prompt.category || 'ganzheitlich'}
</td>
<td style={{ padding: 12, textAlign: 'center', fontSize: 13 }}>
{prompt.type === 'pipeline' ? (
<span style={{
background: 'var(--surface2)',
padding: '2px 6px',
borderRadius: 4,
fontSize: 11
}}>
{getStageCount(prompt)} Stages
</span>
) : (
<span style={{ color: 'var(--text3)', fontSize: 11 }}></span>
)}
</td>
<td style={{ padding: 12, textAlign: 'center' }}>
<label style={{
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer'
}}>
<input
type="checkbox"
checked={prompt.active}
onChange={() => handleToggleActive(prompt)}
style={{ margin: 0 }}
/>
</label>
</td>
<td style={{ padding: 12 }}>
<div style={{
display: 'flex',
gap: 6,
justifyContent: 'flex-end'
}}>
<button
onClick={() => {
if (prompt.type === 'workflow') {
navigate(`/workflow-editor/${prompt.id}`)
} else {
setEditingPrompt(prompt)
}
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Bearbeiten"
>
<Edit size={16} color="var(--accent)" />
</button>
{/* Show convert button for 1-stage pipelines */}
{prompt.type === 'pipeline' && getStageCount(prompt) === 1 && (
<button
onClick={() => handleConvertToBase(prompt)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Zu Basis-Prompt konvertieren"
>
<ArrowDownToLine size={16} color="#6366f1" />
</button>
)}
<button
onClick={() => handleDuplicate(prompt)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Duplizieren"
>
<Copy size={16} color="var(--text3)" />
</button>
<button
onClick={() => handleDelete(prompt)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Löschen"
>
<Trash2 size={16} color="var(--danger)" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* Unified Prompt Modal */}
{(editingPrompt || showNewPrompt) && (
<UnifiedPromptModal
prompt={editingPrompt}
onSave={handleSave}
onClose={() => {
setEditingPrompt(null)
setShowNewPrompt(false)
}}
/>
)}
{/* Import Dialog - 3 Button Choice */}
{importDialogData && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999
}}>
<div style={{
background: 'var(--bg)',
borderRadius: 12,
padding: 24,
maxWidth: 500,
width: '90%',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)'
}}>
<h3 style={{ marginTop: 0, marginBottom: 16, fontSize: 18 }}>
{importDialogData.count} Prompts importieren?
</h3>
<p style={{ marginBottom: 24, color: 'var(--text2)', lineHeight: 1.5 }}>
Wie sollen existierende Prompts behandelt werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
className="btn btn-primary"
onClick={() => handleImportChoice('yes')}
style={{ width: '100%' }}
>
Ja, überschreiben
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>
Bestehende Prompts aktualisieren
</div>
</button>
<button
className="btn"
onClick={() => handleImportChoice('no')}
style={{ width: '100%' }}
>
Nein, nur neue
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>
Nur neue Prompts erstellen, bestehende überspringen
</div>
</button>
<button
className="btn btn-secondary"
onClick={() => handleImportChoice('cancel')}
style={{ width: '100%' }}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}