added role based access
This commit is contained in:
parent
f9ea463ea8
commit
dc4a119a7a
58
src/App.tsx
58
src/App.tsx
@ -13,6 +13,15 @@ import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
|||||||
import { ReportPage } from './screens/ReportPage'
|
import { ReportPage } from './screens/ReportPage'
|
||||||
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||||
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
|
||||||
|
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
|
||||||
|
import { useAuth } from './auth/context'
|
||||||
|
|
||||||
|
function RootRedirect() {
|
||||||
|
const { roles, isAdmin } = useAuth();
|
||||||
|
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { routeConfig } from './routesConfig'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -21,35 +30,32 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<ConsoleLayout />}>
|
<Route element={<ConsoleLayout />}>
|
||||||
<Route path="orders" element={<OrdersPage />} />
|
{routeConfig.map((route, i) => {
|
||||||
<Route path="orders/:instanceId" element={<OrdersPage />} />
|
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
||||||
|
|
||||||
<Route path="my-orders" element={<MyOrdersPage />} />
|
// Maintain nested route for Dataset items specifically
|
||||||
<Route path="my-orders/:instanceId" element={<MyOrdersPage />} />
|
if (path === 'admin/datasets') {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={i}
|
||||||
|
path={path}
|
||||||
|
element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
|
||||||
|
>
|
||||||
|
<Route path=":id" element={<DatasetItemsPage />} />
|
||||||
|
</Route>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Route path="calls" element={<CallsPage />} />
|
return (
|
||||||
<Route path="calls/:instanceId" element={<CallsPage />} />
|
<Route
|
||||||
|
key={i}
|
||||||
|
path={path}
|
||||||
|
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Route path="my-calls" element={<MyCallsPage />} />
|
<Route path="*" element={<RootRedirect />} />
|
||||||
<Route path="my-calls/:instanceId" element={<MyCallsPage />} />
|
|
||||||
|
|
||||||
<Route path="stores" element={<StoresPage />} />
|
|
||||||
<Route path="stores/:instanceId" element={<StoresPage />} />
|
|
||||||
|
|
||||||
<Route path="daily" element={<DailyLogsPage />} />
|
|
||||||
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
|
|
||||||
|
|
||||||
<Route path="my-daily" element={<MyDailyLogsPage />} />
|
|
||||||
<Route path="my-daily/:instanceId" element={<MyDailyLogsPage />} />
|
|
||||||
|
|
||||||
<Route path="reports/:reportType" element={<ReportPage />} />
|
|
||||||
<Route path="sales-report" element={<DailySalesReportPage />} />
|
|
||||||
|
|
||||||
<Route path="admin/datasets" element={<DatasetsPage />}>
|
|
||||||
<Route path=":id" element={<DatasetItemsPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -122,6 +122,17 @@ export class ZinoClient {
|
|||||||
this.setToken(null);
|
this.setToken(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMe(): Promise<User> {
|
||||||
|
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
|
||||||
|
if (this.user) {
|
||||||
|
this.user = { ...this.user, ...res };
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/** Decode the persisted JWT into a User (no network) or use the saved user. */
|
/** Decode the persisted JWT into a User (no network) or use the saved user. */
|
||||||
currentUser(): User | null {
|
currentUser(): User | null {
|
||||||
if (!this.token) return null;
|
if (!this.token) return null;
|
||||||
|
|||||||
@ -30,6 +30,12 @@ const ALL = [orderBookingClient, storeClient, dailyReportsClient];
|
|||||||
export async function loginAll(email: string, password: string, orgId?: string) {
|
export async function loginAll(email: string, password: string, orgId?: string) {
|
||||||
const res = await orderBookingClient.login(email, password, orgId);
|
const res = await orderBookingClient.login(email, password, orgId);
|
||||||
ALL.forEach((c) => c.setToken(res.token));
|
ALL.forEach((c) => c.setToken(res.token));
|
||||||
|
try {
|
||||||
|
const me = await orderBookingClient.getMe();
|
||||||
|
res.user = { ...res.user, ...me };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch user profile:', e);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface User {
|
|||||||
mobile?: string;
|
mobile?: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode, useEffect } from 'react';
|
||||||
import { loginAll, logoutAll, currentToken } from '../api/clients';
|
import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
|
||||||
import { AuthCtx, type AuthValue } from './context';
|
import { AuthCtx, type AuthValue } from './context';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@ -7,9 +7,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [userEmail, setUserEmail] = useState<string | null>(() => {
|
const [userEmail, setUserEmail] = useState<string | null>(() => {
|
||||||
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : null;
|
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : null;
|
||||||
});
|
});
|
||||||
|
const [isAdmin, setIsAdmin] = useState<boolean>(() => {
|
||||||
|
return orderBookingClient.currentUser()?.is_admin ?? false;
|
||||||
|
});
|
||||||
|
const [roles, setRoles] = useState<string[]>(() => {
|
||||||
|
return orderBookingClient.currentUser()?.roles ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it
|
||||||
|
useEffect(() => {
|
||||||
|
if (authed && (!isAdmin || roles.length === 0)) {
|
||||||
|
orderBookingClient.getMe().then(me => {
|
||||||
|
if (me.is_admin) setIsAdmin(true);
|
||||||
|
if (me.roles) setRoles(me.roles);
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [authed]);
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
authed,
|
authed,
|
||||||
|
isAdmin,
|
||||||
|
roles,
|
||||||
userEmail,
|
userEmail,
|
||||||
login: async (email, password, orgId) => {
|
login: async (email, password, orgId) => {
|
||||||
const res = await loginAll(email, password, orgId);
|
const res = await loginAll(email, password, orgId);
|
||||||
@ -18,11 +36,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUserEmail(res.user.email);
|
setUserEmail(res.user.email);
|
||||||
localStorage.setItem('krishna_sales_user_email', res.user.email);
|
localStorage.setItem('krishna_sales_user_email', res.user.email);
|
||||||
}
|
}
|
||||||
|
setIsAdmin(!!res.user?.is_admin);
|
||||||
|
setRoles(res.user?.roles ?? []);
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
logoutAll();
|
logoutAll();
|
||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
|
setIsAdmin(false);
|
||||||
|
setRoles([]);
|
||||||
localStorage.removeItem('krishna_sales_user_email');
|
localStorage.removeItem('krishna_sales_user_email');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/auth/ProtectedRoute.tsx
Normal file
63
src/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Navigate, useNavigate } from 'react-router-dom';
|
||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { useAuth } from './context';
|
||||||
|
import { Button } from '../components/buttons';
|
||||||
|
import { Card } from '../components/reusable';
|
||||||
|
|
||||||
|
export function getDefaultRoute(roles: string[], isAdmin: boolean) {
|
||||||
|
if (isAdmin) return '/orders';
|
||||||
|
if (roles.includes('Manager')) return '/orders';
|
||||||
|
if (roles.includes('Sales Officer')) return '/my-orders';
|
||||||
|
return '/stores';
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessDenied() {
|
||||||
|
const { roles, isAdmin } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[500px] h-full p-4 m-4">
|
||||||
|
<Card
|
||||||
|
className="max-w-md w-full shadow-sm mx-auto"
|
||||||
|
bodyClassName="flex flex-col items-center justify-center text-center p-8"
|
||||||
|
pad={false}
|
||||||
|
>
|
||||||
|
<ShieldAlert className="w-16 h-16 text-red-500 mb-4 opacity-90 mx-auto" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">Access Denied</h2>
|
||||||
|
<p className="text-gray-500 mb-8">
|
||||||
|
You don't have permission to view this page. If you believe this is an error, please contact your administrator.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate(getDefaultRoute(roles, isAdmin))}
|
||||||
|
>
|
||||||
|
Return to Homepage
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({
|
||||||
|
children,
|
||||||
|
roles,
|
||||||
|
adminOnly
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode,
|
||||||
|
roles?: string[],
|
||||||
|
adminOnly?: boolean
|
||||||
|
}) {
|
||||||
|
const { roles: userRoles, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
if (adminOnly && !isAdmin) {
|
||||||
|
return <AccessDenied />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles && roles.length > 0) {
|
||||||
|
const hasRole = roles.some(role => userRoles.includes(role));
|
||||||
|
if (!hasRole) {
|
||||||
|
return <AccessDenied />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ import { createContext, useContext } from 'react';
|
|||||||
|
|
||||||
export interface AuthValue {
|
export interface AuthValue {
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
roles: string[];
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|||||||
107
src/routesConfig.tsx
Normal file
107
src/routesConfig.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { OrdersPage } from './screens/OrdersPage'
|
||||||
|
import { MyOrdersPage } from './screens/MyOrdersPage'
|
||||||
|
import { CallsPage } from './screens/CallsPage'
|
||||||
|
import { MyCallsPage } from './screens/MyCallsPage'
|
||||||
|
import { StoresPage } from './screens/StoresPage'
|
||||||
|
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||||
|
import { MyDailyLogsPage } from './screens/MyDailyLogsPage'
|
||||||
|
import { DailySalesReportPage } from './screens/DailySalesReportPage'
|
||||||
|
import { ReportPage } from './screens/ReportPage'
|
||||||
|
import { DatasetsPage } from './screens/admin/DatasetsPage'
|
||||||
|
|
||||||
|
export const routeConfig = [
|
||||||
|
// Sales Officer
|
||||||
|
{
|
||||||
|
path: "/my-orders",
|
||||||
|
element: <MyOrdersPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-orders/:instanceId",
|
||||||
|
element: <MyOrdersPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-calls",
|
||||||
|
element: <MyCallsPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-calls/:instanceId",
|
||||||
|
element: <MyCallsPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-daily",
|
||||||
|
element: <MyDailyLogsPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-daily/:instanceId",
|
||||||
|
element: <MyDailyLogsPage />,
|
||||||
|
roles: ["Sales Officer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manager & Admin
|
||||||
|
{
|
||||||
|
path: "/orders",
|
||||||
|
element: <OrdersPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/orders/:instanceId",
|
||||||
|
element: <OrdersPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/calls",
|
||||||
|
element: <CallsPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/calls/:instanceId",
|
||||||
|
element: <CallsPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/daily",
|
||||||
|
element: <DailyLogsPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/daily/:instanceId",
|
||||||
|
element: <DailyLogsPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Everyone
|
||||||
|
{
|
||||||
|
path: "/stores",
|
||||||
|
element: <StoresPage />,
|
||||||
|
roles: ["Sales Officer", "Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/stores/:instanceId",
|
||||||
|
element: <StoresPage />,
|
||||||
|
roles: ["Sales Officer", "Manager", "Admin"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/reports/:reportType",
|
||||||
|
element: <ReportPage />,
|
||||||
|
roles: ["Manager", "Admin"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manager + Admin
|
||||||
|
{
|
||||||
|
path: "/sales-report",
|
||||||
|
element: <DailySalesReportPage />,
|
||||||
|
roles: ["Manager", "Admin", "Sales Officer"],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin Panel
|
||||||
|
{
|
||||||
|
path: "/admin/datasets",
|
||||||
|
element: <DatasetsPage />,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -9,9 +9,22 @@ import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
|||||||
import { SCREENS } from './tabs';
|
import { SCREENS } from './tabs';
|
||||||
import { REPORT_MAP } from './ReportPage';
|
import { REPORT_MAP } from './ReportPage';
|
||||||
|
|
||||||
|
import { routeConfig } from '../routesConfig';
|
||||||
|
|
||||||
|
const PATH_LABELS: Record<string, string> = {
|
||||||
|
"/my-orders": "My Orders",
|
||||||
|
"/my-calls": "My Calls",
|
||||||
|
"/my-daily": "My Daily Logs",
|
||||||
|
"/orders": "Orders",
|
||||||
|
"/calls": "Calls",
|
||||||
|
"/daily": "Daily Logs",
|
||||||
|
"/stores": "Stores",
|
||||||
|
"/sales-report": "DSR",
|
||||||
|
};
|
||||||
|
|
||||||
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||||
export function ConsoleLayout() {
|
export function ConsoleLayout() {
|
||||||
const { authed, logout } = useAuth();
|
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
@ -28,6 +41,13 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
if (!authed) return <Navigate to="/login" replace />;
|
if (!authed) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
|
const reportRoute = routeConfig.find(r => r.path === '/reports/:reportType');
|
||||||
|
const canSeeReports = reportRoute && (
|
||||||
|
(reportRoute.adminOnly && isAdmin) ||
|
||||||
|
(reportRoute.roles && reportRoute.roles.some(r => userRoles.includes(r))) ||
|
||||||
|
(!reportRoute.adminOnly && !reportRoute.roles) // open to all
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
|
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
|
||||||
<header className="sticky top-0 z-50 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
|
<header className="sticky top-0 z-50 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
|
||||||
@ -35,11 +55,21 @@ export function ConsoleLayout() {
|
|||||||
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
|
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
|
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
|
||||||
{SCREENS.map((t) => {
|
{routeConfig.filter(route => {
|
||||||
|
if (route.path.includes('/:')) return false; // Skip detail pages
|
||||||
|
if (!PATH_LABELS[route.path]) return false; // Skip unmapped routes
|
||||||
|
|
||||||
|
if (route.adminOnly && !isAdmin) return false;
|
||||||
|
if (!route.adminOnly && route.roles) {
|
||||||
|
return route.roles.some(r => userRoles.includes(r));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).map((route) => {
|
||||||
|
const key = route.path.replace('/', '');
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={t.key}
|
key={key}
|
||||||
to={`/${t.key}`}
|
to={route.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150',
|
'flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150',
|
||||||
@ -47,38 +77,39 @@ export function ConsoleLayout() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t.label}
|
{PATH_LABELS[route.path]}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="relative group flex items-center h-full">
|
{canSeeReports && (
|
||||||
<button className={cn(
|
<div className="relative group flex items-center h-full">
|
||||||
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
|
<button className={cn(
|
||||||
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
|
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
|
||||||
)}>
|
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
|
||||||
Reports
|
)}>
|
||||||
<ChevronDown size={14} className="ml-0.5 opacity-70" />
|
Reports
|
||||||
</button>
|
<ChevronDown size={14} className="ml-0.5 opacity-70" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
|
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
|
||||||
{Object.entries(REPORT_MAP).map(([key, report]) => (
|
{Object.entries(REPORT_MAP).map(([key, report]) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={key}
|
key={key}
|
||||||
to={`/reports/${key}`}
|
to={`/reports/${key}`}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
|
||||||
isActive && "bg-gray-100 font-semibold"
|
isActive && "bg-gray-100 font-semibold"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{report.title}
|
{report.title}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
|
||||||
<button
|
<button
|
||||||
@ -92,15 +123,17 @@ export function ConsoleLayout() {
|
|||||||
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
|
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
|
||||||
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
|
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
|
{isAdmin && (
|
||||||
<NavLink
|
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
|
||||||
to="/admin/datasets"
|
<NavLink
|
||||||
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
|
to="/admin/datasets"
|
||||||
>
|
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
|
||||||
<Database size={16} />
|
>
|
||||||
Manage Datasets
|
<Database size={16} />
|
||||||
</NavLink>
|
Manage Datasets
|
||||||
</div>
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -3,20 +3,28 @@ import { orderBookingClient } from '../../api/clients';
|
|||||||
import type { Dataset } from '../../api/types';
|
import type { Dataset } from '../../api/types';
|
||||||
import { Spinner } from '../../components/reusable/Spinner';
|
import { Spinner } from '../../components/reusable/Spinner';
|
||||||
import { Database } from 'lucide-react';
|
import { Database } from 'lucide-react';
|
||||||
import { useNavigate, useLocation, Outlet, useParams } from 'react-router-dom';
|
import { useNavigate, useLocation, Outlet, useParams, Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../auth/context';
|
||||||
|
|
||||||
export function DatasetsPage() {
|
export function DatasetsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDatasets();
|
if (isAdmin) {
|
||||||
}, []);
|
fetchDatasets();
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return <Navigate to="/orders" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchDatasets = async () => {
|
const fetchDatasets = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
DailyLogDetail,
|
DailyLogDetail,
|
||||||
type WiredDetailViewProps,
|
type WiredDetailViewProps,
|
||||||
} from '../components/dv';
|
} from '../components/dv';
|
||||||
|
import { routeConfig } from '../routesConfig';
|
||||||
|
|
||||||
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report';
|
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report';
|
||||||
|
|
||||||
@ -24,17 +25,22 @@ export interface ScreenDef {
|
|||||||
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
|
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
|
||||||
/** Singular noun for the detail title. */
|
/** Singular noun for the detail title. */
|
||||||
noun: string;
|
noun: string;
|
||||||
|
roles?: string[];
|
||||||
|
adminOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRoles = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.roles;
|
||||||
|
const getAdminOnly = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.adminOnly;
|
||||||
|
|
||||||
export const SCREENS: ScreenDef[] = [
|
export const SCREENS: ScreenDef[] = [
|
||||||
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
|
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order', roles: getRoles('orders'), adminOnly: getAdminOnly('orders') },
|
||||||
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order' },
|
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order', roles: getRoles('my-orders'), adminOnly: getAdminOnly('my-orders') },
|
||||||
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call' },
|
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call', roles: getRoles('calls'), adminOnly: getAdminOnly('calls') },
|
||||||
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My Call' },
|
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My Call', roles: getRoles('my-calls'), adminOnly: getAdminOnly('my-calls') },
|
||||||
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store' },
|
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store', roles: getRoles('stores'), adminOnly: getAdminOnly('stores') },
|
||||||
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log' },
|
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log', roles: getRoles('daily'), adminOnly: getAdminOnly('daily') },
|
||||||
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log' },
|
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log', roles: getRoles('my-daily'), adminOnly: getAdminOnly('my-daily') },
|
||||||
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report' },
|
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report', roles: getRoles('sales-report'), adminOnly: getAdminOnly('sales-report') },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function screenByKey(key: string | undefined): ScreenDef | undefined {
|
export function screenByKey(key: string | undefined): ScreenDef | undefined {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user