mitai-jinkendo/frontend/src/pages/AdminPromptsPage.jsx
Lars 7f94a41965
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: batch import/export for prompts (Issue #28 Debug B)
Dev→Prod Sync in 2 Klicks: Export → Import

Backend:
- GET /api/prompts/export-all → JSON mit allen Prompts
- POST /api/prompts/import?overwrite=true/false → Import + Create/Update
  - Returns: created, updated, skipped counts
  - Validates JSON structure
  - Handles stages JSON conversion

Frontend AdminPromptsPage:
- Button "📦 Alle exportieren" → downloads all-prompts-{date}.json
- Button "📥 Importieren" → file upload dialog
  - User-Prompt: Überschreiben? Ja/Nein
  - Success-Message mit Statistik (created/updated/skipped)

Frontend api.js:
- exportAllPrompts()
- importPrompts(data, overwrite)

Use Cases:
1. Backup: Prompts als JSON sichern
2. Dev→Prod: Auf dev.mitai entwickeln → exportieren → auf mitai.jinkendo importieren
3. Versionierung: Prompts in Git speichern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:44:08 +01:00

589 lines
18 KiB
JavaScript

import { useState, useEffect } from 'react'
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 [prompts, setPrompts] = useState([])
const [filteredPrompts, setFilteredPrompts] = useState([])
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline'
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 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')
}
// 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
setImporting(true)
setError(null)
setImportResult(null)
try {
const text = await file.text()
const data = JSON.parse(text)
// Ask user about overwrite
const overwrite = confirm(
'Bestehende Prompts überschreiben?\n\n' +
'JA = Existierende Prompts aktualisieren\n' +
'NEIN = Nur neue Prompts erstellen, Duplikate überspringen'
)
const result = await api.importPrompts(data, 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>
</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>
</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={() => 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)
}}
/>
)}
</div>
)
}