navigation

This commit is contained in:
2026-04-02 21:00:23 +05:30
parent 36086e4b77
commit 60d817fa8a
7 changed files with 162 additions and 93 deletions

View File

@@ -9,6 +9,14 @@ import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config"; import { AppConfig } from "./types/config";
import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import AppTheme from "../src/shared-theme/AppTheme"; import AppTheme from "../src/shared-theme/AppTheme";
import {
BrowserRouter,
Routes,
Route,
useNavigate,
useParams,
Navigate,
} from "react-router-dom";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -17,26 +25,38 @@ export const ConfigContext = React.createContext<AppConfig | null>(null);
function Dashboard() { function Dashboard() {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate();
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Welcome to the Admin Panel Welcome to the Admin Panel
</Typography> </Typography>
<Typography variant="body1"> <Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data. Select a resource from the sidebar to manage data.
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 3, gap: 3,
mt: 4, mt: 4,
}} }}
> >
{config?.resources.map((res) => ( {config?.resources.map((res) => (
<Paper key={res.name} sx={{ p: 3, textAlign: "center" }}> <Paper
<Typography variant="h6">{res.pluralLabel}</Typography> key={res.name}
sx={{
p: 3,
textAlign: "center",
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
}}
onClick={() => navigate(`/${res.name}`)}
>
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
</Paper> </Paper>
))} ))}
</Box> </Box>
@@ -47,15 +67,7 @@ function Dashboard() {
function AdminApp() { function AdminApp() {
const { currentUser, login, logout, loading, error } = useAuth(); const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const [selectedResourceName, setSelectedResourceName] = React.useState< const navigate = useNavigate();
string | null
>(null);
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
const handleNavigateToResource = (resourceName: string, id: string) => {
setSelectedResourceName(resourceName);
setSelectedItemId(id);
};
if (!currentUser) { if (!currentUser) {
return ( return (
@@ -72,34 +84,34 @@ function AdminApp() {
); );
} }
const selectedResource = config?.resources.find(
(r) => r.name === selectedResourceName
);
return ( return (
<AdminLayout <AdminLayout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
selectedResourceName={selectedResourceName} onSelectResource={(name) => navigate(`/${name}`)}
onSelectResource={(name) => {
setSelectedResourceName(name);
setSelectedItemId(null);
}}
resources={config?.resources || []} resources={config?.resources || []}
> >
{selectedResource ? ( <Routes>
<ResourceView <Route path="/" element={<Dashboard />} />
key={`${selectedResource.name}-${selectedItemId}`} <Route path="/:resourceName" element={<ResourceRouteWrapper />} />
config={selectedResource} <Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
onNavigateToResource={handleNavigateToResource} <Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
/> <Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
) : ( </Routes>
<Dashboard />
)}
</AdminLayout> </AdminLayout>
); );
} }
function ResourceRouteWrapper() {
const { resourceName } = useParams();
const config = React.useContext(ConfigContext);
const selectedResource = config?.resources.find((r) => r.name === resourceName);
if (!selectedResource) return <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} />;
}
export default function App() { export default function App() {
const [config, setConfig] = React.useState<AppConfig | null>(null); const [config, setConfig] = React.useState<AppConfig | null>(null);
@@ -133,7 +145,9 @@ export default function App() {
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
<AuthProvider authBaseUrl={config.authBaseUrl}> <AuthProvider authBaseUrl={config.authBaseUrl}>
<UploadProvider> <UploadProvider>
<AdminApp /> <BrowserRouter>
<AdminApp />
</BrowserRouter>
</UploadProvider> </UploadProvider>
</AuthProvider> </AuthProvider>
</ConfigContext.Provider> </ConfigContext.Provider>

View File

@@ -18,13 +18,13 @@ import TableViewIcon from '@mui/icons-material/TableView';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { useLocation, useNavigate } from 'react-router-dom';
const drawerWidth = 240; const drawerWidth = 240;
interface AdminLayoutProps { interface AdminLayoutProps {
children: React.ReactNode; children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void; onSelectResource: (resourceName: string | null) => void;
selectedResourceName: string | null;
onLogout: () => void; onLogout: () => void;
username?: string; username?: string;
resources: ResourceConfig[]; resources: ResourceConfig[];
@@ -33,11 +33,14 @@ interface AdminLayoutProps {
export default function AdminLayout({ export default function AdminLayout({
children, children,
onSelectResource, onSelectResource,
selectedResourceName,
onLogout, onLogout,
username, username,
resources, resources,
}: AdminLayoutProps) { }: AdminLayoutProps) {
const location = useLocation();
const navigate = useNavigate();
const activeResourceName = location.pathname.split('/')[1] || null;
return ( return (
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<CssBaseline /> <CssBaseline />
@@ -67,8 +70,8 @@ export default function AdminLayout({
<List> <List>
<ListItem disablePadding> <ListItem disablePadding>
<ListItemButton <ListItemButton
selected={selectedResourceName === null} selected={location.pathname === '/'}
onClick={() => onSelectResource(null)} onClick={() => navigate('/')}
> >
<ListItemIcon> <ListItemIcon>
<DashboardIcon /> <DashboardIcon />
@@ -82,7 +85,7 @@ export default function AdminLayout({
{resources.map((res) => ( {resources.map((res) => (
<ListItem key={res.name} disablePadding> <ListItem key={res.name} disablePadding>
<ListItemButton <ListItemButton
selected={selectedResourceName === res.name} selected={activeResourceName === res.name}
onClick={() => onSelectResource(res.name)} onClick={() => onSelectResource(res.name)}
> >
<ListItemIcon> <ListItemIcon>

View File

@@ -15,7 +15,9 @@ import {
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { ResourceConfig, ResourceField } from '../types/config'; import VisibilityIcon from '@mui/icons-material/Visibility';
import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -34,6 +36,7 @@ export default function EnhancedTable({
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
}: EnhancedTableProps) { }: EnhancedTableProps) {
const navigate = useNavigate();
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
@@ -45,6 +48,9 @@ export default function EnhancedTable({
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const value = params.value; const value = params.value;
// 0. Link to View if it's the Primary Key (if it's displayed)
const isPk = key === config.primaryKey;
// 1. Custom Formatter // 1. Custom Formatter
if (field.formatter) { if (field.formatter) {
return field.formatter(value); return field.formatter(value);
@@ -99,7 +105,23 @@ export default function EnhancedTable({
// 4. Default renderings // 4. Default renderings
if (field.type === 'boolean') return value ? 'Yes' : 'No'; if (field.type === 'boolean') return value ? 'Yes' : 'No';
if (field.type === 'datetime' || field.type === 'date') { if (field.type === 'datetime' || field.type === 'date') {
return new Date(value).toLocaleString(); return (value) ? new Date(value).toLocaleString() : '';
}
if (isPk) {
return (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.stopPropagation();
const rowId = params.row[config.primaryKey];
navigate(`/${config.name}/${rowId}`);
}}
>
{value}
</Link>
);
} }
return value; return value;
@@ -112,12 +134,17 @@ export default function EnhancedTable({
field: 'actions', field: 'actions',
type: 'actions', type: 'actions',
headerName: 'Actions', headerName: 'Actions',
width: 100, width: 120,
getActions: (params) => [ getActions: (params) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem <GridActionsCellItem
icon={<EditIcon />} icon={<EditIcon />}
label="Edit" label="Edit"
onClick={() => onEdit(params.row)} onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
/>, />,
<GridActionsCellItem <GridActionsCellItem
icon={<DeleteIcon />} icon={<DeleteIcon />}
@@ -128,7 +155,7 @@ export default function EnhancedTable({
}); });
return cols; return cols;
}, [config, onEdit, onDelete, onNavigateToResource]); }, [config, onDelete, navigate, onNavigateToResource]);
return ( return (
<Box sx={{ height: 600, width: '100%' }}> <Box sx={{ height: 600, width: '100%' }}>
@@ -144,14 +171,10 @@ export default function EnhancedTable({
getRowId={(row) => { getRowId={(row) => {
const pk = config.primaryKey; const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk]; if (row[pk] !== undefined && row[pk] !== null) return row[pk];
// Fallback: search for common ID fields const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
const fallbackKeys = ['id', 'uuid', 'pk'];
for (const key of fallbackKeys) { for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key]; if (row[key] !== undefined && row[key] !== null) return row[key];
} }
debugger;
// Absolute fallback: index (not ideal but avoids crash)
return `temp-id-${data.indexOf(row)}`; return `temp-id-${data.indexOf(row)}`;
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick

View File

@@ -22,6 +22,8 @@ interface GenericFormProps {
onSave: (data: any) => Promise<void>; onSave: (data: any) => Promise<void>;
onCancel: () => void; onCancel: () => void;
loading?: boolean; loading?: boolean;
readOnly?: boolean;
onEditClick?: () => void;
} }
import { ConfigContext } from '../App'; import { ConfigContext } from '../App';
@@ -32,6 +34,8 @@ export default function GenericForm({
onSave, onSave,
onCancel, onCancel,
loading: saving, loading: saving,
readOnly = false,
onEditClick,
}: GenericFormProps) { }: GenericFormProps) {
initialData = initialData || {}; initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData); const [formData, setFormData] = React.useState(initialData);
@@ -39,18 +43,25 @@ export default function GenericForm({
const appConfig = React.useContext(ConfigContext); const appConfig = React.useContext(ConfigContext);
const handleChange = (key: string, value: any) => { const handleChange = (key: string, value: any) => {
if (readOnly) return;
setFormData((prev: any) => ({ ...prev, [key]: value })); setFormData((prev: any) => ({ ...prev, [key]: value }));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (readOnly) return;
onSave(formData); onSave(formData);
}; };
const getTitle = () => {
if (readOnly) return `View ${config.label}`;
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
};
return ( return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h5"> <Typography variant="h5">
{initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`} {getTitle()}
</Typography> </Typography>
<Divider /> <Divider />
@@ -61,7 +72,7 @@ export default function GenericForm({
field={field} field={field}
value={formData[key]} value={formData[key]}
onChange={(val: any) => handleChange(key, val)} onChange={(val: any) => handleChange(key, val)}
disabled={field.readOnly} disabled={readOnly || field.readOnly}
uploadFile={uploadFile} uploadFile={uploadFile}
uploading={uploading} uploading={uploading}
baseUrl={appConfig?.baseUrl || ""} baseUrl={appConfig?.baseUrl || ""}
@@ -70,11 +81,17 @@ export default function GenericForm({
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
<Button variant="outlined" onClick={onCancel} disabled={saving}> <Button variant="outlined" onClick={onCancel} disabled={saving}>
Cancel {readOnly ? 'Back to List' : 'Cancel'}
</Button>
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button> </Button>
{readOnly ? (
<Button variant="contained" color="primary" onClick={onEditClick}>
Edit {config.label}
</Button>
) : (
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button>
)}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -4,6 +4,7 @@ import { ResourceConfig } from '../types/config';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
@@ -11,68 +12,75 @@ interface ResourceViewProps {
} }
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list'); const { id } = useParams();
const [selectedItem, setSelectedItem] = React.useState<any>(null); const location = useLocation();
const navigate = useNavigate();
const isCreate = location.pathname.endsWith('/create');
const isEdit = location.pathname.includes('/edit/');
const isView = !!id && !isEdit;
const isList = !id && !isCreate;
const { useList, useCreate, useUpdate, useDelete } = useResource(config); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
const { data, isLoading, error } = useList(); const listQuery = useList();
const itemQuery = useRead(id || "");
const createMutation = useCreate(); const createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
const handleEdit = (item: any) => { const handleEdit = (item: any) => {
setSelectedItem(item); navigate(`/${config.name}/edit/${item[config.primaryKey]}`);
setView('edit');
}; };
const handleCreate = () => { const handleCreate = () => {
setSelectedItem(null); navigate(`/${config.name}/create`);
setView('create');
}; };
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
try { try {
if (view === 'edit') { if (isEdit) {
const id = formData[config.primaryKey]; await updateMutation.mutateAsync({ id: id!, data: formData });
await updateMutation.mutateAsync({ id, data: formData });
} else { } else {
await createMutation.mutateAsync(formData); await createMutation.mutateAsync(formData);
} }
setView('list'); navigate(`/${config.name}`);
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Save failed:', err);
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (itemId: string) => {
if (window.confirm('Are you sure you want to delete this item?')) { if (window.confirm('Are you sure you want to delete this item?')) {
await deleteMutation.mutateAsync(id); await deleteMutation.mutateAsync(itemId);
} }
}; };
if (isLoading) return <CircularProgress />; if (isList && listQuery.isLoading) return <CircularProgress />;
if (error) return <Typography color="error">Error loading {config.pluralLabel}</Typography>; if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
return ( return (
<Box> <Box>
{view === 'list' ? ( {isList ? (
<EnhancedTable <EnhancedTable
config={config} config={config}
data={data || []} data={listQuery.data || []}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCreate={handleCreate} onCreate={handleCreate}
onNavigateToResource={onNavigateToResource} onNavigateToResource={(res, id) => navigate(`/${res}/${id}`)}
/> />
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
<GenericForm <GenericForm
config={config} config={config}
initialData={selectedItem} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
onCancel={() => setView('list')} onCancel={() => navigate(`/${config.name}`)}
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/${config.name}/edit/${id}`)}
/> />
</Paper> </Paper>
)} )}

View File

@@ -7,6 +7,7 @@ interface ImageUploadFieldProps {
onUpload: (file: File) => void; onUpload: (file: File) => void;
size?: number; size?: number;
baseUrl: string; baseUrl: string;
disabled?: boolean;
} }
export default function ImageUploadField({ export default function ImageUploadField({
@@ -16,6 +17,7 @@ export default function ImageUploadField({
onUpload, onUpload,
size = 64, size = 64,
baseUrl, baseUrl,
disabled = false,
}: ImageUploadFieldProps) { }: ImageUploadFieldProps) {
const imgSrc = value const imgSrc = value
@@ -33,23 +35,25 @@ export default function ImageUploadField({
sx={{ width: size, height: size, borderRadius: 2 }} sx={{ width: size, height: size, borderRadius: 2 }}
/> />
<Button {!disabled && (
variant="outlined" <Button
component="label" variant="outlined"
disabled={uploading} component="label"
startIcon={uploading && <CircularProgress size={16} />} disabled={uploading}
> startIcon={uploading && <CircularProgress size={16} />}
{uploading ? "Uploading..." : "Choose File"} >
<input {uploading ? "Uploading..." : "Choose File"}
type="file" <input
accept="image/*" type="file"
hidden accept="image/*"
onChange={(e) => { hidden
const file = e.target.files?.[0]; onChange={(e) => {
if (file) onUpload(file); const file = e.target.files?.[0];
}} if (file) onUpload(file);
/> }}
</Button> />
</Button>
)}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -17,7 +17,7 @@ export function useResource<T = any>(config: ResourceConfig) {
}); });
// --- READ ONE --- // --- READ ONE ---
const useOne = (id: string | null) => const useRead = (id: string | null) =>
useQuery({ useQuery({
queryKey: [name, "detail", id], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
@@ -69,7 +69,7 @@ export function useResource<T = any>(config: ResourceConfig) {
return { return {
useList, useList,
useOne, useRead,
useCreate, useCreate,
useUpdate, useUpdate,
useDelete, useDelete,