first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user