mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 01:52:04 +00:00
feat: add protected layout and dashboard module
This commit is contained in:
parent
33a902034c
commit
60572c7e96
6 changed files with 533 additions and 0 deletions
39
src/app/(protected)/_layout.tsx
Normal file
39
src/app/(protected)/_layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
import {
|
||||
AppSidebar,
|
||||
SIDEBAR_COLLAPSED_WIDTH,
|
||||
SIDEBAR_WIDTH,
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||
|
||||
export default function AdminShellLayout() {
|
||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||
const sidebarWidth = navCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Box className="flex min-h-screen w-full bg-background">
|
||||
<AppSidebar collapsed={navCollapsed} />
|
||||
|
||||
<Box
|
||||
className="flex min-h-screen min-w-0 flex-1 flex-col"
|
||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||
>
|
||||
<AppTopbar onToggleNav={() => setNavCollapsed((value) => !value)} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className="flex-1 p-2.5"
|
||||
sx={{ backgroundColor: "var(--color-bg-page)" }}
|
||||
>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
221
src/app/(protected)/dashboard.tsx
Normal file
221
src/app/(protected)/dashboard.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
|
||||
|
||||
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
|
||||
|
||||
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
value?: string | number;
|
||||
icon: ReactNode;
|
||||
color: "success" | "primary" | "info" | "warning";
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
|
||||
const colorMap = {
|
||||
success: "success.main",
|
||||
primary: "primary.main",
|
||||
info: "info.main",
|
||||
warning: "warning.main",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
onClick={onClick}
|
||||
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
|
||||
sx={{
|
||||
borderLeftWidth: 4,
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: colorMap[color],
|
||||
}}
|
||||
>
|
||||
<Box className="flex w-full gap-4 p-4">
|
||||
<Box
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
|
||||
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
|
||||
<Box className="flex min-w-0 flex-col justify-center gap-1">
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
{title}
|
||||
</Typography>
|
||||
{value !== undefined && (
|
||||
<Typography variant="h4" component="p" className="font-bold leading-tight">
|
||||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
|
||||
|
||||
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="h5" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||
<KpiCard
|
||||
title="Create a Work Order"
|
||||
description="Report a problem you need fixed"
|
||||
icon={<AddIcon />}
|
||||
color="success"
|
||||
onClick={() => navigate("/workorders/new")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Open Work Orders"
|
||||
value={formatStat(stats?.open)}
|
||||
icon={<FolderOpenOutlinedIcon />}
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Not Dispatched"
|
||||
value={formatStat(stats?.notDispatched)}
|
||||
icon={<ScheduleOutlinedIcon />}
|
||||
color="info"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Completed Orders"
|
||||
value={formatStat(stats?.completed)}
|
||||
icon={<CheckCircleOutlineIcon />}
|
||||
color="warning"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Priority Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Age of open Work Orders by Priority
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Priority</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Total
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
0-14 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
15-29 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
30-59 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
60+ Days
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{PRIORITY_LEVELS.map((priority) => (
|
||||
<TableRow key={priority}>
|
||||
<TableCell>{priority}</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Invoice Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Count
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Amount
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{INVOICE_STATUSES.map((status) => (
|
||||
<TableRow key={status}>
|
||||
<TableCell>{status}</TableCell>
|
||||
<TableCell align="right">0</TableCell>
|
||||
<TableCell align="right">$0.00</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
221
src/app/(protected)/index.tsx
Normal file
221
src/app/(protected)/index.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats";
|
||||
|
||||
const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const;
|
||||
|
||||
const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const;
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
value?: string | number;
|
||||
icon: ReactNode;
|
||||
color: "success" | "primary" | "info" | "warning";
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) {
|
||||
const colorMap = {
|
||||
success: "success.main",
|
||||
primary: "primary.main",
|
||||
info: "info.main",
|
||||
warning: "warning.main",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
onClick={onClick}
|
||||
className="flex min-h-[140px] flex-1 cursor-pointer transition-shadow hover:shadow-md"
|
||||
sx={{
|
||||
borderLeftWidth: 4,
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: colorMap[color],
|
||||
}}
|
||||
>
|
||||
<Box className="flex w-full gap-4 p-4">
|
||||
<Box
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full"
|
||||
sx={{ bgcolor: `${colorMap[color]}22`, color: colorMap[color] }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
|
||||
<Box className="flex min-w-0 flex-col justify-center gap-1">
|
||||
<Typography variant="subtitle1" className="font-semibold">
|
||||
{title}
|
||||
</Typography>
|
||||
{value !== undefined && (
|
||||
<Typography variant="h4" component="p" className="font-bold leading-tight">
|
||||
{loading ? "..." : typeof value === "number" ? value.toLocaleString() : value}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: stats, isLoading, isFetching, error } = useDashboardStats();
|
||||
|
||||
const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0));
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
<Stack direction="row" spacing={2} alignItems="baseline">
|
||||
<Typography variant="h5" component="h1">
|
||||
Dashboard
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load dashboard stats"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", lg: "row" }} spacing={2} useFlexGap flexWrap="wrap">
|
||||
<KpiCard
|
||||
title="Create a Work Order"
|
||||
description="Report a problem you need fixed"
|
||||
icon={<AddIcon />}
|
||||
color="success"
|
||||
onClick={() => navigate("/workorders/new")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Open Work Orders"
|
||||
value={formatStat(stats?.open)}
|
||||
icon={<FolderOpenOutlinedIcon />}
|
||||
color="primary"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Not Dispatched"
|
||||
value={formatStat(stats?.notDispatched)}
|
||||
icon={<ScheduleOutlinedIcon />}
|
||||
color="info"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Completed Orders"
|
||||
value={formatStat(stats?.completed)}
|
||||
icon={<CheckCircleOutlineIcon />}
|
||||
color="warning"
|
||||
loading={isLoading}
|
||||
onClick={() => navigate("/workorders")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Priority Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Age of open Work Orders by Priority
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Priority</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Total
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
0-14 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
15-29 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
30-59 Days
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
60+ Days
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{PRIORITY_LEVELS.map((priority) => (
|
||||
<TableRow key={priority}>
|
||||
<TableCell>{priority}</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
<TableCell align="right">—</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" className="overflow-hidden">
|
||||
<Box className="border-b border-border px-4 py-3">
|
||||
<Typography variant="h6" component="h2">
|
||||
Invoice Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Status</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Count
|
||||
</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
Amount
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{INVOICE_STATUSES.map((status) => (
|
||||
<TableRow key={status}>
|
||||
<TableCell>{status}</TableCell>
|
||||
<TableCell align="right">0</TableCell>
|
||||
<TableCell align="right">$0.00</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
src/domain/dashboard/api/dashboard-api.ts
Normal file
35
src/domain/dashboard/api/dashboard-api.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiGet } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mapDashboardStats(raw: unknown): DashboardStats {
|
||||
const data = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
open: toNumber(data.open ?? data.Open),
|
||||
notDispatched: toNumber(data.notDispatched ?? data.NotDispatched),
|
||||
completed: toNumber(data.completed ?? data.Completed),
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: async (): Promise<DashboardStats> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.dashboard.stats);
|
||||
const response = handleApiResponse<unknown>(data);
|
||||
return mapDashboardStats(response);
|
||||
},
|
||||
};
|
||||
5
src/domain/dashboard/types/dashboard-stats.ts
Normal file
5
src/domain/dashboard/types/dashboard-stats.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface DashboardStats {
|
||||
open: number;
|
||||
notDispatched: number;
|
||||
completed: number;
|
||||
}
|
||||
12
src/domain/dashboard/use-cases/use-dashboard-stats.ts
Normal file
12
src/domain/dashboard/use-cases/use-dashboard-stats.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "@/domain/dashboard/api/dashboard-api";
|
||||
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export function useDashboardStats(): UseQueryResult<DashboardStats, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dashboard.stats(),
|
||||
queryFn: () => dashboardApi.getStats(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue