433 lines
18 KiB
TypeScript
433 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|