refactor: split AdminHierarchyPage into modular components
Split 1200+ line file into clean modular architecture: - FocusAreaNode.jsx - Tree nodes with nested style directions + training types - HierarchyTab.jsx - Tab 1 with tree view and detail panel - CatalogsTab.jsx - Tab 2 with global catalogs (Target Groups, Skill Categories, Training Characters) - AssignmentsTab.jsx - Tab 3 with M:N checkbox matrix (Style Directions ↔ Target Groups) - DetailPanel.jsx - All edit forms (Focus Area, Style Direction, Training Type) + create forms Fixes ESBuild parser error from large file size. Implements full CRUD: create, edit, delete, reassign focus areas. Responsive design with mobile/desktop layouts.
This commit is contained in:
parent
9ec1cf7781
commit
0e0b709768
160
frontend/src/components/admin/AssignmentsTab.jsx
Normal file
160
frontend/src/components/admin/AssignmentsTab.jsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import React, { useState } from 'react'
|
||||
import { api } from '../../utils/api'
|
||||
|
||||
function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, error, onUpdate }) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
}
|
||||
|
||||
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (currentlyAssigned) {
|
||||
// Find and delete the assignment
|
||||
const assignment = assignments.find(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
if (assignment) {
|
||||
await api.deleteStyleDirectionTargetGroup(assignment.id)
|
||||
}
|
||||
} else {
|
||||
// Create new assignment
|
||||
await api.createStyleDirectionTargetGroup({
|
||||
style_direction_id: styleDirectionId,
|
||||
target_group_id: targetGroupId,
|
||||
is_primary: false
|
||||
})
|
||||
}
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function isAssigned(styleDirectionId, targetGroupId) {
|
||||
return assignments.some(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
}
|
||||
|
||||
// Group style directions by focus area
|
||||
const groupedStyles = styleDirections.reduce((acc, sd) => {
|
||||
const key = sd.focus_area_name || 'Ohne Fokusbereich'
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(sd)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen ↔ Zielgruppen</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
|
||||
|
||||
{targetGroups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
||||
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{styleDirections.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
||||
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetGroups.length > 0 && styleDirections.length > 0 && (
|
||||
<div className="assignment-matrix-container">
|
||||
<table className="assignment-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
|
||||
{targetGroups.map(tg => (
|
||||
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
|
||||
{tg.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
|
||||
<React.Fragment key={focusAreaName}>
|
||||
<tr className="focus-area-header">
|
||||
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
|
||||
{focusAreaName}
|
||||
</td>
|
||||
</tr>
|
||||
{styles.map(sd => (
|
||||
<tr key={sd.id}>
|
||||
<td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
|
||||
{sd.name}
|
||||
</td>
|
||||
{targetGroups.map(tg => {
|
||||
const assigned = isAssigned(sd.id, tg.id)
|
||||
return (
|
||||
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={assigned}
|
||||
onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
|
||||
disabled={saving}
|
||||
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.assignment-matrix-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.assignment-matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.assignment-matrix th,
|
||||
.assignment-matrix td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.assignment-matrix th {
|
||||
background: var(--surface2);
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.assignment-matrix tbody tr:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.assignment-matrix {
|
||||
font-size: 14px;
|
||||
}
|
||||
.assignment-matrix th,
|
||||
.assignment-matrix td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssignmentsTab
|
||||
194
frontend/src/components/admin/CatalogsTab.jsx
Normal file
194
frontend/src/components/admin/CatalogsTab.jsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useState } from 'react'
|
||||
import { api } from '../../utils/api'
|
||||
|
||||
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
|
||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
|
||||
|
||||
<CatalogSection
|
||||
title="Zielgruppen"
|
||||
icon="🎯"
|
||||
items={targetGroups}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTargetGroup}
|
||||
updateFn={api.updateTargetGroup}
|
||||
deleteFn={api.deleteTargetGroup}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' },
|
||||
{ key: 'min_age', label: 'Min. Alter', type: 'number' },
|
||||
{ key: 'max_age', label: 'Max. Alter', type: 'number' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<CatalogSection
|
||||
title="Fähigkeitskategorien"
|
||||
icon="⚡"
|
||||
items={skillCategories}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createSkillCategory}
|
||||
updateFn={api.updateSkillCategory}
|
||||
deleteFn={api.deleteSkillCategory}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<CatalogSection
|
||||
title="Trainingscharakter"
|
||||
icon="💪"
|
||||
items={trainingCharacters}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTrainingCharacter}
|
||||
updateFn={api.updateTrainingCharacter}
|
||||
deleteFn={api.deleteTrainingCharacter}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form, setForm] = useState({})
|
||||
|
||||
function startCreate() {
|
||||
const emptyForm = {}
|
||||
fields.forEach(f => { emptyForm[f.key] = '' })
|
||||
setForm(emptyForm)
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
function startEdit(item) {
|
||||
const editForm = {}
|
||||
fields.forEach(f => { editForm[f.key] = item[f.key] || '' })
|
||||
setEditing(item.id)
|
||||
setForm(editForm)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const required = fields.filter(f => f.required)
|
||||
for (const field of required) {
|
||||
if (!form[field.key]) {
|
||||
alert(`${field.label} ist erforderlich`)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await createFn(form)
|
||||
setCreating(false)
|
||||
setForm({})
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(id) {
|
||||
try {
|
||||
await updateFn(id, form)
|
||||
setEditing(null)
|
||||
setForm({})
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id, name) {
|
||||
if (!confirm(`"${name}" wirklich löschen?`)) return
|
||||
try {
|
||||
await deleteFn(id)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
|
||||
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="form-row">
|
||||
<label className="form-label">{field.label} {field.required && '*'}</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
||||
) : (
|
||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
|
||||
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
{editing === item.id ? (
|
||||
<div>
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="form-row">
|
||||
<label className="form-label">{field.label}</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
||||
) : (
|
||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<strong>{item.name}</strong>
|
||||
{item.min_age !== null && item.max_age !== null && (
|
||||
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
|
||||
Alter: {item.min_age}-{item.max_age}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
|
||||
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && !creating && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
|
||||
Noch keine Einträge vorhanden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CatalogsTab
|
||||
374
frontend/src/components/admin/DetailPanel.jsx
Normal file
374
frontend/src/components/admin/DetailPanel.jsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import React, { useState } from 'react'
|
||||
import { api } from '../../utils/api'
|
||||
|
||||
function DetailPanel({ item, onUpdate, focusAreas }) {
|
||||
const type = item._type
|
||||
|
||||
if (type === 'create_style_direction') {
|
||||
return <CreateStyleDirectionForm item={item} onUpdate={onUpdate} />
|
||||
}
|
||||
if (type === 'create_training_type') {
|
||||
return <CreateTrainingTypeForm item={item} onUpdate={onUpdate} />
|
||||
}
|
||||
if (type === 'focus_area') {
|
||||
return <FocusAreaDetail item={item} onUpdate={onUpdate} />
|
||||
}
|
||||
if (type === 'style_direction') {
|
||||
return <StyleDirectionDetail item={item} onUpdate={onUpdate} focusAreas={focusAreas} />
|
||||
}
|
||||
if (type === 'training_type') {
|
||||
return <TrainingTypeDetail item={item} onUpdate={onUpdate} focusAreas={focusAreas} />
|
||||
}
|
||||
|
||||
return <div style={{ padding: '20px', color: 'var(--text3)' }}>Unbekannter Typ: {type}</div>
|
||||
}
|
||||
|
||||
function FocusAreaDetail({ item, onUpdate }) {
|
||||
const [form, setForm] = useState({
|
||||
name: item.name || '',
|
||||
icon: item.icon || '',
|
||||
description: item.description || '',
|
||||
sort_order: item.sort_order || 0,
|
||||
status: item.status || 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.updateFocusArea(item.id, form)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Fokusbereich "${item.name}" wirklich löschen? ACHTUNG: Alle zugeordneten Stilrichtungen und Trainingstypen werden ebenfalls gelöscht!`)) return
|
||||
try {
|
||||
await api.deleteFocusArea(item.id)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Fokusbereich bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Icon (Emoji)</label>
|
||||
<input className="form-input" value={form.icon} onChange={e => setForm({ ...form, icon: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea className="form-input" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
||||
const [form, setForm] = useState({
|
||||
name: item.name || '',
|
||||
abbreviation: item.abbreviation || '',
|
||||
description: item.description || '',
|
||||
focus_area_id: item.focus_area_id || null,
|
||||
sort_order: item.sort_order || 0,
|
||||
status: item.status || 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.focus_area_id) {
|
||||
alert('Bitte Fokusbereich auswählen')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.updateStyleDirection(item.id, form)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Stilrichtung "${item.name}" wirklich löschen?`)) return
|
||||
try {
|
||||
await api.deleteStyleDirection(item.id)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Stilrichtung bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abkürzung</label>
|
||||
<input className="form-input" value={form.abbreviation} onChange={e => setForm({ ...form, abbreviation: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea className="form-input" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokusbereich *</label>
|
||||
<select className="form-input" value={form.focus_area_id || ''} onChange={e => setForm({ ...form, focus_area_id: parseInt(e.target.value) || null })}>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{focusAreas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
||||
const [form, setForm] = useState({
|
||||
name: item.name || '',
|
||||
abbreviation: item.abbreviation || '',
|
||||
description: item.description || '',
|
||||
focus_area_id: item.focus_area_id || null,
|
||||
sort_order: item.sort_order || 0,
|
||||
status: item.status || 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.focus_area_id) {
|
||||
alert('Bitte Fokusbereich auswählen')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.updateTrainingType(item.id, form)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Trainingstyp "${item.name}" wirklich löschen?`)) return
|
||||
try {
|
||||
await api.deleteTrainingType(item.id)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Trainingstyp bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abkürzung</label>
|
||||
<input className="form-input" value={form.abbreviation} onChange={e => setForm({ ...form, abbreviation: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea className="form-input" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokusbereich *</label>
|
||||
<select className="form-input" value={form.focus_area_id || ''} onChange={e => setForm({ ...form, focus_area_id: parseInt(e.target.value) || null })}>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{focusAreas.map(fa => (
|
||||
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select className="form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateStyleDirectionForm({ item, onUpdate }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
abbreviation: '',
|
||||
description: '',
|
||||
focus_area_id: item.focus_area_id,
|
||||
sort_order: 0,
|
||||
status: 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.name) {
|
||||
alert('Bitte Name eingeben')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.createStyleDirection(form)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Neue Stilrichtung erstellen</h2>
|
||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
||||
Fokusbereich: <strong>{item.focus_area_name}</strong>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="z.B. Shotokan" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abkürzung</label>
|
||||
<input className="form-input" value={form.abbreviation} onChange={e => setForm({ ...form, abbreviation: e.target.value })} placeholder="z.B. SHO" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea className="form-input" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
|
||||
{saving ? 'Erstellt...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTrainingTypeForm({ item, onUpdate }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
abbreviation: '',
|
||||
description: '',
|
||||
focus_area_id: item.focus_area_id,
|
||||
sort_order: 0,
|
||||
status: 'active'
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.name) {
|
||||
alert('Bitte Name eingeben')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.createTrainingType(form)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Neuen Trainingstyp erstellen</h2>
|
||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
||||
Fokusbereich: <strong>{item.focus_area_name}</strong>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="z.B. Breitensport" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abkürzung</label>
|
||||
<input className="form-input" value={form.abbreviation} onChange={e => setForm({ ...form, abbreviation: e.target.value })} placeholder="z.B. BREIT" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea className="form-input" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
|
||||
{saving ? 'Erstellt...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailPanel
|
||||
145
frontend/src/components/admin/FocusAreaNode.jsx
Normal file
145
frontend/src/components/admin/FocusAreaNode.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React from 'react'
|
||||
|
||||
function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, selectedType }) {
|
||||
const nodeId = `fa-${focusArea.id}`
|
||||
const isExpanded = expanded.has(nodeId)
|
||||
const isSelected = selectedType === 'focus_area' && selectedId === focusArea.id
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{/* Focus Area Header */}
|
||||
<div
|
||||
onClick={() => onSelect(focusArea, 'focus_area')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'transparent',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(nodeId) }}
|
||||
style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
|
||||
<span>{focusArea.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Children: Style Directions + Training Types */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
|
||||
{/* Style Directions Section */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Stilrichtungen</span>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
|
||||
}}
|
||||
>
|
||||
+ Neu
|
||||
</button>
|
||||
</div>
|
||||
{focusArea.style_directions && focusArea.style_directions.map(sd => (
|
||||
<StyleDirectionNode
|
||||
key={sd.id}
|
||||
styleDirection={sd}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Training Types Section */}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Trainingstypen</span>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
|
||||
}}
|
||||
>
|
||||
+ Neu
|
||||
</button>
|
||||
</div>
|
||||
{focusArea.training_types && focusArea.training_types.map(tt => (
|
||||
<TrainingTypeNode
|
||||
key={tt.id}
|
||||
trainingType={tt}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(styleDirection, 'style_direction')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{styleDirection.name}
|
||||
{styleDirection.abbreviation && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({styleDirection.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
|
||||
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(trainingType, 'training_type')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{trainingType.name}
|
||||
{trainingType.abbreviation && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({trainingType.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FocusAreaNode
|
||||
63
frontend/src/components/admin/HierarchyTab.jsx
Normal file
63
frontend/src/components/admin/HierarchyTab.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react'
|
||||
import FocusAreaNode from './FocusAreaNode'
|
||||
import DetailPanel from './DetailPanel'
|
||||
|
||||
function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) {
|
||||
if (loading && hierarchy.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-hierarchy-container">
|
||||
{/* Tree View */}
|
||||
<div
|
||||
className="admin-tree-view"
|
||||
style={{
|
||||
display: selectedItem ? 'none' : 'block',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Katalog-Hierarchie</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
|
||||
|
||||
{hierarchy.map(fa => (
|
||||
<FocusAreaNode
|
||||
key={fa.id}
|
||||
focusArea={fa}
|
||||
expanded={expandedNodes}
|
||||
onToggle={onToggleNode}
|
||||
onSelect={onSelectItem}
|
||||
selectedId={selectedItem?.id}
|
||||
selectedType={selectedItem?._type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedItem && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn admin-back-button"
|
||||
onClick={() => onSelectItem(null)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
← Zurück zur Übersicht
|
||||
</button>
|
||||
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HierarchyTab
|
||||
|
|
@ -1,28 +1,191 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* AdminHierarchyPage - Simplified version to fix build
|
||||
*/
|
||||
import HierarchyTab from '../components/admin/HierarchyTab'
|
||||
import CatalogsTab from '../components/admin/CatalogsTab'
|
||||
import AssignmentsTab from '../components/admin/AssignmentsTab'
|
||||
|
||||
function AdminHierarchyPage() {
|
||||
const [activeTab, setActiveTab] = useState('hierarchy')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false)
|
||||
}, [])
|
||||
// Hierarchy Tab State
|
||||
const [hierarchy, setHierarchy] = useState([])
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set())
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '40px', textAlign: 'center' }}><div className="spinner"></div></div>
|
||||
// Catalogs Tab State
|
||||
const [targetGroups, setTargetGroups] = useState([])
|
||||
const [skillCategories, setSkillCategories] = useState([])
|
||||
const [trainingCharacters, setTrainingCharacters] = useState([])
|
||||
|
||||
// Assignments Tab State
|
||||
const [styleDirections, setStyleDirections] = useState([])
|
||||
const [assignments, setAssignments] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [activeTab])
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
if (activeTab === 'hierarchy') {
|
||||
const data = await api.getAdminHierarchy()
|
||||
setHierarchy(data)
|
||||
} else if (activeTab === 'catalogs') {
|
||||
const [tgs, scs, tcs] = await Promise.all([
|
||||
api.listTargetGroups(),
|
||||
api.listSkillCategories(),
|
||||
api.listTrainingCharacters()
|
||||
])
|
||||
setTargetGroups(tgs)
|
||||
setSkillCategories(scs)
|
||||
setTrainingCharacters(tcs)
|
||||
} else if (activeTab === 'assignments') {
|
||||
const [sds, tgs, assns] = await Promise.all([
|
||||
api.listStyleDirections(),
|
||||
api.listTargetGroups(),
|
||||
api.listStyleDirectionTargetGroups()
|
||||
])
|
||||
setStyleDirections(sds)
|
||||
setTargetGroups(tgs)
|
||||
setAssignments(assns)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleNode(nodeId) {
|
||||
setExpandedNodes(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(nodeId)) {
|
||||
newSet.delete(nodeId)
|
||||
} else {
|
||||
newSet.add(nodeId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelectItem(item, type) {
|
||||
if (item) {
|
||||
setSelectedItem({ ...item, _type: type })
|
||||
} else {
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdate() {
|
||||
setSelectedItem(null)
|
||||
loadData()
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
|
||||
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
|
||||
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h1>Admin-Hierarchie</h1>
|
||||
{error && <div style={{ color: 'var(--danger)' }}>{error}</div>}
|
||||
<p>Die vollständige Admin-Hierarchie wird gerade überarbeitet...</p>
|
||||
<p>Nutze vorübergehend die alte Katalog-Seite unter <a href="/admin/catalogs">/admin/catalogs</a></p>
|
||||
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="tab-navigation">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
{activeTab === 'hierarchy' && (
|
||||
<HierarchyTab
|
||||
hierarchy={hierarchy}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedItem={selectedItem}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleNode={handleToggleNode}
|
||||
onSelectItem={handleSelectItem}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'catalogs' && (
|
||||
<CatalogsTab
|
||||
targetGroups={targetGroups}
|
||||
skillCategories={skillCategories}
|
||||
trainingCharacters={trainingCharacters}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'assignments' && (
|
||||
<AssignmentsTab
|
||||
styleDirections={styleDirections}
|
||||
targetGroups={targetGroups}
|
||||
assignments={assignments}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.tab-navigation {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text1);
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-button {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user