diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx new file mode 100644 index 00000000..ee42fcc0 --- /dev/null +++ b/src/app/(protected)/_layout.tsx @@ -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 ( + + + + + + setNavCollapsed((value) => !value)} /> + + + + + + + + ); +} diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx new file mode 100644 index 00000000..14041e42 --- /dev/null +++ b/src/app/(protected)/dashboard.tsx @@ -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 ( + + + + {icon} + + + + + {title} + + {value !== undefined && ( + + {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} + + )} + {description && ( + + {description} + + )} + + + + ); +} + +export default function DashboardPage() { + const navigate = useNavigate(); + const { data: stats, isLoading, isFetching, error } = useDashboardStats(); + + const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); + + return ( + + + + Dashboard + + {isFetching && !isLoading && } + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load dashboard stats"} + + )} + + + } + color="success" + onClick={() => navigate("/workorders/new")} + /> + } + color="primary" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="info" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="warning" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + + + + + + Priority Analysis + + + Age of open Work Orders by Priority + + + + + + + + Priority + + Total + + + 0-14 Days + + + 15-29 Days + + + 30-59 Days + + + 60+ Days + + + + + {PRIORITY_LEVELS.map((priority) => ( + + {priority} + — + — + — + — + — + + ))} + +
+
+
+ + + + + Invoice Analysis + + + + + + + + Status + + Count + + + Amount + + + + + {INVOICE_STATUSES.map((status) => ( + + {status} + 0 + $0.00 + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx new file mode 100644 index 00000000..14041e42 --- /dev/null +++ b/src/app/(protected)/index.tsx @@ -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 ( + + + + {icon} + + + + + {title} + + {value !== undefined && ( + + {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} + + )} + {description && ( + + {description} + + )} + + + + ); +} + +export default function DashboardPage() { + const navigate = useNavigate(); + const { data: stats, isLoading, isFetching, error } = useDashboardStats(); + + const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); + + return ( + + + + Dashboard + + {isFetching && !isLoading && } + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load dashboard stats"} + + )} + + + } + color="success" + onClick={() => navigate("/workorders/new")} + /> + } + color="primary" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="info" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + } + color="warning" + loading={isLoading} + onClick={() => navigate("/workorders")} + /> + + + + + + Priority Analysis + + + Age of open Work Orders by Priority + + + + + + + + Priority + + Total + + + 0-14 Days + + + 15-29 Days + + + 30-59 Days + + + 60+ Days + + + + + {PRIORITY_LEVELS.map((priority) => ( + + {priority} + — + — + — + — + — + + ))} + +
+
+
+ + + + + Invoice Analysis + + + + + + + + Status + + Count + + + Amount + + + + + {INVOICE_STATUSES.map((status) => ( + + {status} + 0 + $0.00 + + ))} + +
+
+
+
+ ); +} diff --git a/src/domain/dashboard/api/dashboard-api.ts b/src/domain/dashboard/api/dashboard-api.ts new file mode 100644 index 00000000..15cbc6ce --- /dev/null +++ b/src/domain/dashboard/api/dashboard-api.ts @@ -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; + + 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 => { + const data = await apiGet(API_PATHS.dashboard.stats); + const response = handleApiResponse(data); + return mapDashboardStats(response); + }, +}; diff --git a/src/domain/dashboard/types/dashboard-stats.ts b/src/domain/dashboard/types/dashboard-stats.ts new file mode 100644 index 00000000..7b7aee4b --- /dev/null +++ b/src/domain/dashboard/types/dashboard-stats.ts @@ -0,0 +1,5 @@ +export interface DashboardStats { + open: number; + notDispatched: number; + completed: number; +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-stats.ts b/src/domain/dashboard/use-cases/use-dashboard-stats.ts new file mode 100644 index 00000000..eb1b9a5f --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-stats.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.dashboard.stats(), + queryFn: () => dashboardApi.getStats(), + staleTime: 60 * 1000, + }); +}