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

View File

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

View File

@@ -15,7 +15,9 @@ import {
} from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit';
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 {
config: ResourceConfig;
@@ -34,6 +36,7 @@ export default function EnhancedTable({
onCreate,
onNavigateToResource,
}: EnhancedTableProps) {
const navigate = useNavigate();
const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
@@ -45,6 +48,9 @@ export default function EnhancedTable({
renderCell: (params: GridRenderCellParams) => {
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
if (field.formatter) {
return field.formatter(value);
@@ -99,7 +105,23 @@ export default function EnhancedTable({
// 4. Default renderings
if (field.type === 'boolean') return value ? 'Yes' : 'No';
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;
@@ -112,12 +134,17 @@ export default function EnhancedTable({
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 100,
width: 120,
getActions: (params) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => onEdit(params.row)}
onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
@@ -128,7 +155,7 @@ export default function EnhancedTable({
});
return cols;
}, [config, onEdit, onDelete, onNavigateToResource]);
}, [config, onDelete, navigate, onNavigateToResource]);
return (
<Box sx={{ height: 600, width: '100%' }}>
@@ -144,14 +171,10 @@ export default function EnhancedTable({
getRowId={(row) => {
const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
// Fallback: search for common ID fields
const fallbackKeys = ['id', 'uuid', 'pk'];
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
for (const key of fallbackKeys) {
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)}`;
}}
disableRowSelectionOnClick

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ interface ImageUploadFieldProps {
onUpload: (file: File) => void;
size?: number;
baseUrl: string;
disabled?: boolean;
}
export default function ImageUploadField({
@@ -16,6 +17,7 @@ export default function ImageUploadField({
onUpload,
size = 64,
baseUrl,
disabled = false,
}: ImageUploadFieldProps) {
const imgSrc = value
@@ -33,6 +35,7 @@ export default function ImageUploadField({
sx={{ width: size, height: size, borderRadius: 2 }}
/>
{!disabled && (
<Button
variant="outlined"
component="label"
@@ -50,6 +53,7 @@ export default function ImageUploadField({
}}
/>
</Button>
)}
</Box>
</Box>
);

View File

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