Files
opencrm/frontend/src/pages/developer/DatabaseStructure.tsx
T
Stefan Hacker e209e9bbca first commit
2026-01-29 01:16:54 +01:00

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>
);
}