mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
Merge pull request #23 from johndoe6345789/copilot/refactor-app-folder-structure
Refactor admin dashboard to be config-driven using features.json
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -81,7 +81,7 @@
|
|||||||
"storybook": "^10.1.11",
|
"storybook": "^10.1.11",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.15",
|
||||||
"vitest-browser-react": "^2.0.2"
|
"vitest-browser-react": "^2.0.2"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
"storybook": "^10.1.11",
|
"storybook": "^10.1.11",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.15",
|
||||||
"vitest-browser-react": "^2.0.2"
|
"vitest-browser-react": "^2.0.2"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,369 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CodeIcon from '@mui/icons-material/Code';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
|
||||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AppBar,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Drawer,
|
|
||||||
IconButton,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Toolbar,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { theme } from '@/utils/theme';
|
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
|
||||||
|
|
||||||
type TabPanelProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
index: number;
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function TabPanel(props: TabPanelProps) {
|
|
||||||
const { children, value, index, ...other } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tabpanel"
|
|
||||||
hidden={value !== index}
|
|
||||||
id={`tabpanel-${index}`}
|
|
||||||
aria-labelledby={`tab-${index}`}
|
|
||||||
{...other}
|
|
||||||
>
|
|
||||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [tabValue, setTabValue] = useState(0);
|
|
||||||
const [tables, setTables] = useState<any[]>([]);
|
|
||||||
const [selectedTable, setSelectedTable] = useState<string>('');
|
|
||||||
const [queryText, setQueryText] = useState('');
|
|
||||||
const [queryResult, setQueryResult] = useState<any>(null);
|
|
||||||
const [tableSchema, setTableSchema] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
|
||||||
|
|
||||||
// Dialog states
|
|
||||||
const [openCreateDialog, setOpenCreateDialog] = useState(false);
|
|
||||||
const [openEditDialog, setOpenEditDialog] = useState(false);
|
|
||||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
|
||||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
|
||||||
const [deletingRecord, setDeletingRecord] = useState<any>(null);
|
|
||||||
const [formData, setFormData] = useState<any>({});
|
|
||||||
|
|
||||||
const fetchTables = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/tables');
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
router.push('/admin/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to fetch tables');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setTables(data.tables);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTables();
|
|
||||||
}, [fetchTables]);
|
|
||||||
|
|
||||||
const handleTableClick = async (tableName: string) => {
|
|
||||||
setSelectedTable(tableName);
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
setSuccessMessage('');
|
|
||||||
setQueryResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch table data
|
|
||||||
const dataResponse = await fetch('/api/admin/table-data', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ tableName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await dataResponse.json();
|
|
||||||
throw new Error(data.error || 'Query failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await dataResponse.json();
|
|
||||||
setQueryResult(data);
|
|
||||||
|
|
||||||
// Fetch table schema
|
|
||||||
const schemaResponse = await fetch('/api/admin/table-schema', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ tableName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (schemaResponse.ok) {
|
|
||||||
const schemaData = await schemaResponse.json();
|
|
||||||
setTableSchema(schemaData);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuerySubmit = async () => {
|
|
||||||
if (!queryText.trim()) {
|
|
||||||
setError('Please enter a query');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
setQueryResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/admin/query', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query: queryText }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Query failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
setQueryResult(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await fetch('/api/admin/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
router.push('/admin/login');
|
|
||||||
router.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Logout error:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<Box sx={{ display: 'flex' }}>
|
|
||||||
<AppBar
|
|
||||||
position="fixed"
|
|
||||||
sx={{ zIndex: theme => theme.zIndex.drawer + 1 }}
|
|
||||||
>
|
|
||||||
<Toolbar>
|
|
||||||
<StorageIcon sx={{ mr: 2 }} />
|
|
||||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
|
||||||
Postgres Admin Panel
|
|
||||||
</Typography>
|
|
||||||
<Button color="inherit" onClick={handleLogout} startIcon={<LogoutIcon />}>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
|
|
||||||
<Drawer
|
|
||||||
variant="permanent"
|
|
||||||
sx={{
|
|
||||||
'width': DRAWER_WIDTH,
|
|
||||||
'flexShrink': 0,
|
|
||||||
'& .MuiDrawer-paper': {
|
|
||||||
width: DRAWER_WIDTH,
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Toolbar />
|
|
||||||
<Box sx={{ overflow: 'auto' }}>
|
|
||||||
<List>
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemButton onClick={() => setTabValue(0)}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<StorageIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Tables" />
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem disablePadding>
|
|
||||||
<ListItemButton onClick={() => setTabValue(1)}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<CodeIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="SQL Query" />
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
component="main"
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
bgcolor: 'background.default',
|
|
||||||
p: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Toolbar />
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={0}>
|
|
||||||
<Typography variant="h5" gutterBottom>
|
|
||||||
Database Tables
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Paper sx={{ mt: 2, mb: 2 }}>
|
|
||||||
<List>
|
|
||||||
{tables.map(table => (
|
|
||||||
<ListItem key={table.table_name} disablePadding>
|
|
||||||
<ListItemButton onClick={() => handleTableClick(table.table_name)}>
|
|
||||||
<ListItemIcon>
|
|
||||||
<StorageIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary={table.table_name} />
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{selectedTable && (
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Table:
|
|
||||||
{' '}
|
|
||||||
{selectedTable}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={tabValue} index={1}>
|
|
||||||
<Typography variant="h5" gutterBottom>
|
|
||||||
SQL Query Interface
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Paper sx={{ p: 2, mt: 2 }}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
multiline
|
|
||||||
rows={6}
|
|
||||||
label="SQL Query (SELECT only)"
|
|
||||||
variant="outlined"
|
|
||||||
value={queryText}
|
|
||||||
onChange={e => setQueryText(e.target.value)}
|
|
||||||
placeholder="SELECT * FROM your_table LIMIT 10;"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleQuerySubmit}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? <CircularProgress size={24} /> : 'Execute Query'}
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mt: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{queryResult && !loading && (
|
|
||||||
<Paper sx={{ mt: 2, overflow: 'auto' }}>
|
|
||||||
<Box sx={{ p: 2 }}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Rows returned:
|
|
||||||
{' '}
|
|
||||||
{queryResult.rowCount}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<TableContainer>
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
{queryResult.fields?.map((field: any) => (
|
|
||||||
<TableCell key={field.name}>
|
|
||||||
<strong>{field.name}</strong>
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{queryResult.rows?.map((row: any, idx: number) => (
|
|
||||||
<TableRow key={idx}>
|
|
||||||
{queryResult.fields?.map((field: any) => (
|
|
||||||
<TableCell key={field.name}>
|
|
||||||
{row[field.name] !== null
|
|
||||||
? String(row[field.name])
|
|
||||||
: 'NULL'}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
72
src/components/admin/SQLQueryTab.tsx
Normal file
72
src/components/admin/SQLQueryTab.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { getFeatureById } from '@/utils/featureConfig';
|
||||||
|
|
||||||
|
type SQLQueryTabProps = {
|
||||||
|
onExecuteQuery: (query: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SQLQueryTab({ onExecuteQuery }: SQLQueryTabProps) {
|
||||||
|
const [queryText, setQueryText] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Get feature configuration from JSON
|
||||||
|
const feature = getFeatureById('sql-query');
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (!queryText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onExecuteQuery(queryText);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
{feature?.name || 'SQL Query Interface'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{feature?.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{feature.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper sx={{ p: 2, mt: 2 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
label="SQL Query (SELECT only)"
|
||||||
|
variant="outlined"
|
||||||
|
value={queryText}
|
||||||
|
onChange={e => setQueryText(e.target.value)}
|
||||||
|
placeholder="SELECT * FROM your_table LIMIT 10;"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleExecute}
|
||||||
|
disabled={loading || !queryText.trim()}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} /> : 'Execute Query'}
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/admin/TablesTab.tsx
Normal file
72
src/components/admin/TablesTab.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { getFeatureById } from '@/utils/featureConfig';
|
||||||
|
|
||||||
|
type TablesTabProps = {
|
||||||
|
tables: Array<{ table_name: string }>;
|
||||||
|
selectedTable: string;
|
||||||
|
onTableClick: (tableName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TablesTab({
|
||||||
|
tables,
|
||||||
|
selectedTable,
|
||||||
|
onTableClick,
|
||||||
|
}: TablesTabProps) {
|
||||||
|
// Get feature configuration from JSON
|
||||||
|
const feature = getFeatureById('database-crud');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
{feature?.name || 'Database Tables'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{feature?.description && (
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{feature.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<List>
|
||||||
|
{tables.map(table => (
|
||||||
|
<ListItem key={table.table_name} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selectedTable === table.table_name}
|
||||||
|
onClick={() => onTableClick(table.table_name)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<StorageIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={table.table_name} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{tables.length === 0 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText primary="No tables found" />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{selectedTable && (
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Table: {selectedTable}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2863,6 +2863,12 @@
|
|||||||
"icon": "TableChart",
|
"icon": "TableChart",
|
||||||
"featureId": "table-management"
|
"featureId": "table-management"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "column-manager",
|
||||||
|
"label": "Column Manager",
|
||||||
|
"icon": "ViewColumn",
|
||||||
|
"featureId": "column-management"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "constraints",
|
"id": "constraints",
|
||||||
"label": "Constraints",
|
"label": "Constraints",
|
||||||
|
|||||||
Reference in New Issue
Block a user