first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit e209e9bbca
12105 changed files with 2480672 additions and 0 deletions
@@ -0,0 +1,432 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { developerApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal';
import { Database, Table, ArrowRight, Edit, Trash2, Save, X, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react';
import ERDiagram from './ERDiagram';
interface TableMeta {
name: string;
model: string;
primaryKey: string;
readonlyFields: string[];
requiredFields: string[];
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
foreignKeys: { field: string; targetTable: string }[];
}
export default function DatabaseStructure() {
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [editingRow, setEditingRow] = useState<{ id: string; data: Record<string, any> } | null>(null);
const [showERDiagram, setShowERDiagram] = useState(false);
const queryClient = useQueryClient();
const { data: schemaData, isLoading: schemaLoading, error: schemaError } = useQuery({
queryKey: ['developer-schema'],
queryFn: developerApi.getSchema,
});
// Debug logging
console.log('Schema data:', schemaData);
console.log('Schema error:', schemaError);
const { data: tableData, isLoading: tableLoading } = useQuery({
queryKey: ['developer-table', selectedTable, page],
queryFn: () => developerApi.getTableData(selectedTable!, page),
enabled: !!selectedTable,
});
const updateMutation = useMutation({
mutationFn: ({ tableName, id, data }: { tableName: string; id: string; data: Record<string, any> }) =>
developerApi.updateRow(tableName, id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
setEditingRow(null);
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Fehler beim Speichern');
},
});
const deleteMutation = useMutation({
mutationFn: ({ tableName, id }: { tableName: string; id: string }) =>
developerApi.deleteRow(tableName, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['developer-table', selectedTable] });
},
onError: (error: any) => {
alert(error.response?.data?.error || 'Fehler beim Löschen');
},
});
const tables: TableMeta[] = schemaData?.data || [];
const currentTableMeta = tables.find((t) => t.name === selectedTable);
const getRowId = (row: any, meta: TableMeta) => {
if (meta.primaryKey.includes(',')) {
return meta.primaryKey.split(',').map((k) => row[k]).join('-');
}
return String(row[meta.primaryKey]);
};
const formatValue = (value: any): string => {
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
if (typeof value === 'object') {
if (value instanceof Date || (typeof value === 'string' && value.match(/^\d{4}-\d{2}-\d{2}/))) {
return new Date(value).toLocaleString('de-DE');
}
return JSON.stringify(value);
}
return String(value);
};
const handleSaveEdit = () => {
if (!editingRow || !selectedTable) return;
updateMutation.mutate({
tableName: selectedTable,
id: editingRow.id,
data: editingRow.data,
});
};
const handleDelete = (id: string) => {
if (!selectedTable) return;
if (!confirm('Datensatz wirklich löschen?')) return;
deleteMutation.mutate({ tableName: selectedTable, id });
};
if (schemaLoading) {
return <div className="text-center py-8">Laden...</div>;
}
const handleDiagramSelectTable = (tableName: string) => {
setSelectedTable(tableName);
setPage(1);
setShowERDiagram(false);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Database className="w-6 h-6" />
<h1 className="text-2xl font-bold">Datenbankstruktur</h1>
</div>
<Button onClick={() => setShowERDiagram(true)}>
<GitBranch className="w-4 h-4 mr-2" />
ER-Diagramm
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Tabellen-Liste */}
<Card title="Tabellen" className="lg:col-span-1">
<div className="space-y-1 max-h-[600px] overflow-y-auto">
{tables.map((table) => (
<button
key={table.name}
onClick={() => {
setSelectedTable(table.name);
setPage(1);
}}
className={`w-full text-left px-3 py-2 rounded-lg flex items-center gap-2 transition-colors ${
selectedTable === table.name
? 'bg-blue-100 text-blue-700'
: 'hover:bg-gray-100'
}`}
>
<Table className="w-4 h-4" />
<span className="text-sm font-mono">{table.name}</span>
</button>
))}
</div>
</Card>
{/* Tabellen-Details und Daten */}
<div className="lg:col-span-3 space-y-6">
{selectedTable && currentTableMeta ? (
<>
{/* Beziehungen */}
<Card title={`${selectedTable} - Beziehungen`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Fremdschlüssel (referenziert)</h4>
{currentTableMeta.foreignKeys.length > 0 ? (
<div className="space-y-1">
{currentTableMeta.foreignKeys.map((fk) => (
<div key={fk.field} className="flex items-center gap-2 text-sm">
<span className="font-mono text-gray-600">{fk.field}</span>
<ArrowRight className="w-4 h-4 text-gray-400" />
<Badge
variant="info"
className="cursor-pointer"
onClick={() => {
setSelectedTable(fk.targetTable);
setPage(1);
}}
>
{fk.targetTable}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Keine</p>
)}
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Relationen (wird referenziert von)</h4>
{currentTableMeta.relations.length > 0 ? (
<div className="space-y-1">
{currentTableMeta.relations.map((rel) => (
<div key={rel.field} className="flex items-center gap-2 text-sm">
<span className="font-mono text-gray-600">{rel.field}</span>
<Badge variant={rel.type === 'many' ? 'warning' : 'default'}>
{rel.type === 'many' ? '1:n' : '1:1'}
</Badge>
<Badge
variant="info"
className="cursor-pointer"
onClick={() => {
setSelectedTable(rel.targetTable);
setPage(1);
}}
>
{rel.targetTable}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">Keine</p>
)}
</div>
</div>
<div className="mt-4 pt-4 border-t">
<div className="flex gap-4 text-sm">
<div>
<span className="text-gray-500">Primary Key:</span>{' '}
<span className="font-mono">{currentTableMeta.primaryKey}</span>
</div>
<div>
<span className="text-gray-500">Readonly:</span>{' '}
<span className="font-mono text-red-600">{currentTableMeta.readonlyFields.join(', ') || '-'}</span>
</div>
<div>
<span className="text-gray-500">Required:</span>{' '}
<span className="font-mono text-green-600">{currentTableMeta.requiredFields.join(', ') || '-'}</span>
</div>
</div>
</div>
</Card>
{/* Daten-Tabelle */}
<Card title={`${selectedTable} - Daten`}>
{tableLoading ? (
<div className="text-center py-4">Laden...</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
{tableData?.data && tableData.data.length > 0 &&
Object.keys(tableData.data[0]).map((key) => (
<th key={key} className="text-left py-2 px-3 font-medium text-gray-600 whitespace-nowrap">
{key}
{currentTableMeta.readonlyFields.includes(key) && (
<span className="ml-1 text-red-400 text-xs">*</span>
)}
{currentTableMeta.requiredFields.includes(key) && (
<span className="ml-1 text-green-400 text-xs">!</span>
)}
</th>
))}
<th className="text-right py-2 px-3 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{tableData?.data?.map((row: any) => {
const rowId = getRowId(row, currentTableMeta);
return (
<tr key={rowId} className="border-b hover:bg-gray-50">
{Object.entries(row).map(([key, value]) => (
<td key={key} className="py-2 px-3 font-mono text-xs max-w-[200px] truncate">
{formatValue(value)}
</td>
))}
<td className="py-2 px-3 text-right whitespace-nowrap">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingRow({ id: rowId, data: { ...row } })}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(rowId)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</td>
</tr>
);
})}
{(!tableData?.data || tableData.data.length === 0) && (
<tr>
<td colSpan={100} className="py-4 text-center text-gray-500">
Keine Daten vorhanden
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{(tableData as any)?.pagination && (tableData as any).pagination.totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Seite {(tableData as any).pagination.page} von {(tableData as any).pagination.totalPages} ({(tableData as any).pagination.total} Einträge)
</p>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= (tableData as any).pagination.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</Card>
</>
) : (
<Card>
<div className="text-center py-8 text-gray-500">
Wähle eine Tabelle aus der Liste aus
</div>
</Card>
)}
</div>
</div>
{/* Edit Modal */}
<Modal
isOpen={!!editingRow}
onClose={() => setEditingRow(null)}
title={`${selectedTable} bearbeiten`}
>
{editingRow && currentTableMeta && (
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
{Object.entries(editingRow.data).map(([key, value]) => {
const isReadonly = currentTableMeta.readonlyFields.includes(key);
const isRequired = currentTableMeta.requiredFields.includes(key);
return (
<div key={key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{isReadonly && <span className="ml-1 text-red-400">(readonly)</span>}
{isRequired && <span className="ml-1 text-green-600">*</span>}
</label>
{isReadonly ? (
<div className="px-3 py-2 bg-gray-100 rounded-lg font-mono text-sm">
{formatValue(value)}
</div>
) : typeof value === 'boolean' ? (
<select
value={String(editingRow.data[key])}
onChange={(e) =>
setEditingRow({
...editingRow,
data: { ...editingRow.data, [key]: e.target.value === 'true' },
})
}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="true">Ja</option>
<option value="false">Nein</option>
</select>
) : (
<input
type={typeof value === 'number' ? 'number' : 'text'}
value={editingRow.data[key] ?? ''}
onChange={(e) =>
setEditingRow({
...editingRow,
data: {
...editingRow.data,
[key]: typeof value === 'number' ? (e.target.value ? Number(e.target.value) : null) : e.target.value || null,
},
})
}
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
disabled={isReadonly}
/>
)}
</div>
);
})}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="secondary" onClick={() => setEditingRow(null)}>
<X className="w-4 h-4 mr-2" />
Abbrechen
</Button>
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
<Save className="w-4 h-4 mr-2" />
{updateMutation.isPending ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</div>
)}
</Modal>
{/* ER Diagram Modal */}
{showERDiagram && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowERDiagram(false)} />
<div className="relative bg-white rounded-xl shadow-2xl w-[90vw] h-[85vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="flex items-center gap-3">
<GitBranch className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold">ER-Diagramm - Datenbankbeziehungen</h2>
</div>
<button
onClick={() => setShowERDiagram(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-hidden">
<ERDiagram onSelectTable={handleDiagramSelectTable} />
</div>
</div>
</div>
)}
</div>
);
}
+368
View File
@@ -0,0 +1,368 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { developerApi } from '../../services/api';
import { ZoomIn, ZoomOut, Maximize2, Move } from 'lucide-react';
import Button from '../../components/ui/Button';
interface TableMeta {
name: string;
model: string;
primaryKey: string;
readonlyFields: string[];
requiredFields: string[];
relations: { field: string; targetTable: string; type: 'one' | 'many' }[];
foreignKeys: { field: string; targetTable: string }[];
}
interface TablePosition {
x: number;
y: number;
}
interface ERDiagramProps {
onSelectTable?: (tableName: string) => void;
}
export default function ERDiagram({ onSelectTable }: ERDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [tablePositions, setTablePositions] = useState<Record<string, TablePosition>>({});
const [draggingTable, setDraggingTable] = useState<string | null>(null);
const { data: schemaData, isLoading } = useQuery({
queryKey: ['developer-schema'],
queryFn: developerApi.getSchema,
});
const tables: TableMeta[] = schemaData?.data || [];
// Calculate initial positions in a grid layout
useEffect(() => {
if (tables.length > 0 && Object.keys(tablePositions).length === 0) {
const cols = Math.ceil(Math.sqrt(tables.length));
const spacing = { x: 280, y: 200 };
const newPositions: Record<string, TablePosition> = {};
tables.forEach((table, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
newPositions[table.name] = {
x: 50 + col * spacing.x,
y: 50 + row * spacing.y,
};
});
setTablePositions(newPositions);
}
}, [tables, tablePositions]);
// Pan handling
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'svg') {
setIsDragging(true);
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isDragging && !draggingTable) {
setPan({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
} else if (draggingTable) {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
setTablePositions(prev => ({
...prev,
[draggingTable]: {
x: (e.clientX - rect.left - pan.x) / zoom - 100,
y: (e.clientY - rect.top - pan.y) / zoom - 20,
},
}));
}
}
}, [isDragging, draggingTable, dragStart, pan, zoom]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setDraggingTable(null);
}, []);
const handleZoom = (delta: number) => {
setZoom(prev => Math.min(2, Math.max(0.3, prev + delta)));
};
const handleReset = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};
// Calculate connection points between tables
const getConnections = useCallback(() => {
const connections: Array<{
from: { table: string; x: number; y: number };
to: { table: string; x: number; y: number };
type: 'one' | 'many';
label: string;
}> = [];
tables.forEach(table => {
const fromPos = tablePositions[table.name];
if (!fromPos) return;
table.foreignKeys.forEach(fk => {
const toPos = tablePositions[fk.targetTable];
if (!toPos) return;
// Find the relation type from the target table
const targetTable = tables.find(t => t.name === fk.targetTable);
const relation = targetTable?.relations.find(r => r.targetTable === table.name);
connections.push({
from: { table: table.name, x: fromPos.x + 100, y: fromPos.y + 60 },
to: { table: fk.targetTable, x: toPos.x + 100, y: toPos.y + 60 },
type: relation?.type || 'one',
label: fk.field,
});
});
});
return connections;
}, [tables, tablePositions]);
if (isLoading) {
return <div className="flex items-center justify-center h-full">Laden...</div>;
}
const connections = getConnections();
return (
<div className="relative h-full w-full bg-gray-50 overflow-hidden" ref={containerRef}>
{/* Toolbar */}
<div className="absolute top-4 right-4 z-10 flex gap-2 bg-white rounded-lg shadow-md p-2">
<Button variant="ghost" size="sm" onClick={() => handleZoom(0.1)} title="Vergrößern">
<ZoomIn className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleZoom(-0.1)} title="Verkleinern">
<ZoomOut className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleReset} title="Zurücksetzen">
<Maximize2 className="w-4 h-4" />
</Button>
<div className="text-xs text-gray-500 flex items-center px-2">
{Math.round(zoom * 100)}%
</div>
</div>
{/* Instructions */}
<div className="absolute top-4 left-4 z-10 bg-white rounded-lg shadow-md p-2 text-xs text-gray-500">
<Move className="w-3 h-3 inline mr-1" />
Tabellen ziehen zum Verschieben
</div>
{/* SVG Canvas */}
<svg
className="w-full h-full cursor-grab"
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{/* Connection lines */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
</marker>
<marker
id="many-marker"
markerWidth="12"
markerHeight="12"
refX="6"
refY="6"
orient="auto"
>
<circle cx="6" cy="6" r="3" fill="#6b7280" />
</marker>
</defs>
{connections.map((conn, idx) => {
const dx = conn.to.x - conn.from.x;
const dy = conn.to.y - conn.from.y;
const midX = conn.from.x + dx / 2;
const midY = conn.from.y + dy / 2;
// Calculate control points for curved line
const ctrl1X = conn.from.x + dx * 0.25;
const ctrl1Y = conn.from.y;
const ctrl2X = conn.from.x + dx * 0.75;
const ctrl2Y = conn.to.y;
return (
<g key={idx}>
<path
d={`M ${conn.from.x} ${conn.from.y} C ${ctrl1X} ${ctrl1Y}, ${ctrl2X} ${ctrl2Y}, ${conn.to.x} ${conn.to.y}`}
fill="none"
stroke="#9ca3af"
strokeWidth="2"
markerEnd="url(#arrowhead)"
/>
{/* Relation type indicator */}
<text
x={midX}
y={midY - 8}
fontSize="10"
fill="#6b7280"
textAnchor="middle"
className="select-none"
>
{conn.type === 'many' ? '1:n' : '1:1'}
</text>
</g>
);
})}
{/* Table boxes */}
{tables.map(table => {
const pos = tablePositions[table.name];
if (!pos) return null;
const boxWidth = 200;
const headerHeight = 32;
const fieldHeight = 20;
const fields = [...new Set([table.primaryKey, ...table.foreignKeys.map(fk => fk.field)])];
const boxHeight = headerHeight + Math.min(fields.length, 5) * fieldHeight + 8;
return (
<g
key={table.name}
transform={`translate(${pos.x}, ${pos.y})`}
style={{ cursor: 'move' }}
onMouseDown={(e) => {
e.stopPropagation();
setDraggingTable(table.name);
}}
>
{/* Shadow */}
<rect
x="3"
y="3"
width={boxWidth}
height={boxHeight}
rx="6"
fill="rgba(0,0,0,0.1)"
/>
{/* Box background */}
<rect
x="0"
y="0"
width={boxWidth}
height={boxHeight}
rx="6"
fill="white"
stroke="#e5e7eb"
strokeWidth="1"
/>
{/* Header */}
<rect
x="0"
y="0"
width={boxWidth}
height={headerHeight}
rx="6"
fill="#3b82f6"
className="cursor-pointer"
onClick={() => onSelectTable?.(table.name)}
/>
<rect
x="0"
y={headerHeight - 6}
width={boxWidth}
height="6"
fill="#3b82f6"
/>
<text
x={boxWidth / 2}
y="21"
fontSize="13"
fontWeight="bold"
fill="white"
textAnchor="middle"
className="select-none pointer-events-none"
>
{table.name}
</text>
{/* Fields */}
{fields.slice(0, 5).map((field, fieldIdx) => {
const isPK = field === table.primaryKey || table.primaryKey.includes(field);
const isFK = table.foreignKeys.some(fk => fk.field === field);
return (
<g key={field} transform={`translate(8, ${headerHeight + 4 + fieldIdx * fieldHeight})`}>
<text
x="0"
y="14"
fontSize="11"
fill={isPK ? '#dc2626' : isFK ? '#2563eb' : '#374151'}
fontFamily="monospace"
className="select-none"
>
{isPK && '🔑 '}
{isFK && !isPK && '🔗 '}
{field}
</text>
</g>
);
})}
{fields.length > 5 && (
<text
x={boxWidth / 2}
y={boxHeight - 4}
fontSize="10"
fill="#9ca3af"
textAnchor="middle"
className="select-none"
>
+{fields.length - 5} mehr...
</text>
)}
</g>
);
})}
</g>
</svg>
{/* Legend */}
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-md p-3 text-xs">
<div className="font-medium mb-2">Legende</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-red-600">🔑</span>
<span>Primary Key</span>
</div>
<div className="flex items-center gap-2">
<span className="text-blue-600">🔗</span>
<span>Foreign Key</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-0.5 bg-gray-400"></div>
<span>Beziehung</span>
</div>
</div>
</div>
</div>
);
}