Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ func (srv *Server) registerDashboardRoutes() []routeDef {
newAdminRoute("GET /api/department-metrics/login-trend", srv.handleDepartmentLoginTrend),
newAdminRoute("GET /api/department-metrics/facility-comparison", srv.handleFacilityEngagementComparison),
newAdminRoute("GET /api/department-metrics/knowledge-center", srv.handleKnowledgeCenterMetrics),
newAdminRoute("GET /api/department-metrics/programs/engagement-overview", srv.handleProgramEngagementOverview),
newAdminRoute("GET /api/department-metrics/programs/second-enrollment", srv.handleSecondProgramEnrollment),
newAdminRoute("GET /api/department-metrics/programs/completion-matrix", srv.handleProgramCompletionMatrix),
newAdminRoute("GET /api/department-metrics/programs/load-distribution", srv.handleProgramLoadDistribution),
newAdminRoute("GET /api/department-metrics/programs/enrollment-by-type", srv.handleEnrollmentByProgramType),
newAdminRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics),
newAdminRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary),
newAdminFeatureRoute("GET /api/department-metrics/programs/engagement-overview", srv.handleProgramEngagementOverview, models.ProgramAccess),
newAdminFeatureRoute("GET /api/department-metrics/programs/second-enrollment", srv.handleSecondProgramEnrollment, models.ProgramAccess),
newAdminFeatureRoute("GET /api/department-metrics/programs/completion-matrix", srv.handleProgramCompletionMatrix, models.ProgramAccess),
newAdminFeatureRoute("GET /api/department-metrics/programs/load-distribution", srv.handleProgramLoadDistribution, models.ProgramAccess),
newAdminFeatureRoute("GET /api/department-metrics/programs/enrollment-by-type", srv.handleEnrollmentByProgramType, models.ProgramAccess),
newAdminFeatureRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics, models.ProgramAccess),
newAdminFeatureRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary, models.ProgramAccess),
newAdminRoute("GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2),
validatedRoute("GET /api/users/{id}/profile", srv.handleResidentProfile, resolver),
}
Expand Down
9 changes: 9 additions & 0 deletions backend/src/handlers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ func newAdminRoute(method string, handler HttpFunc) routeDef {
}
}

func newAdminFeatureRoute(method string, handler HttpFunc, features ...models.FeatureAccess) routeDef {
return routeDef{
routeMethod: method,
handler: handler,
admin: true,
features: features,
}
}

func newDeptAdminRoute(method string, handler HttpFunc) routeDef {
return routeDef{
routeMethod: method,
Expand Down
13 changes: 7 additions & 6 deletions backend/tests/integration/programs_core_routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func TestProgramsCoreRoutes(t *testing.T) {
}

// TestProgramsCoreRouteAccess pins who may call core's Programs dashboard and
// metrics routes: any admin, with or without program_management.
// metrics routes: an admin with program_management.
func TestProgramsCoreRouteAccess(t *testing.T) {
env := SetupTestEnv(t)
defer env.CleanupTestEnv()
Expand All @@ -195,11 +195,12 @@ func TestProgramsCoreRouteAccess(t *testing.T) {
NewRequest[any](client, t, http.MethodGet, path, nil).
WithTestClaims(resident).Do().
ExpectStatus(http.StatusUnauthorized).ExpectMessage("Unauthorized - not admin")
for _, c := range []*handlers.Claims{facAdmin, withFeatures(facAdmin, models.ProgramAccess)} {
resp := NewRequest[any](client, t, http.MethodGet, path, nil).
WithTestClaims(c).AsRaw().Do()
require.NotEqual(t, http.StatusUnauthorized, resp.resp.StatusCode, resp.rawBody)
}
NewRequest[any](client, t, http.MethodGet, path, nil).
WithTestClaims(withFeatures(facAdmin, models.ProgramAccess)).Do().
ExpectStatus(http.StatusUnauthorized).ExpectMessage("Feature not enabled")
resp := NewRequest[any](client, t, http.MethodGet, path, nil).
WithTestClaims(facAdmin).AsRaw().Do()
require.NotEqual(t, http.StatusUnauthorized, resp.resp.StatusCode, resp.rawBody)
})
}
}
Expand Down
89 changes: 70 additions & 19 deletions frontend/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useMemo, ComponentProps } from 'react';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import { useAuth, canSwitchFacility } from '@/auth/useAuth';
import { useAuth, canSwitchFacility, hasFeature } from '@/auth/useAuth';
import {
ClassMetrics,
FeatureAccess,
FacilityHealthSummary,
MissingAttendanceItem,
TodaysScheduleItem,
Expand Down Expand Up @@ -51,29 +52,39 @@ function SurfaceCard({ className, ...props }: ComponentProps<'div'>) {
export default function Dashboard() {
const { user } = useAuth();
const deptAdmin = user ? canSwitchFacility(user) : false;
const programsEnabled =
!!user && hasFeature(user, FeatureAccess.ProgramAccess);
const { data: classMetricsResp } = useSWR<ServerResponseOne<ClassMetrics>>(
deptAdmin
? '/api/dashboard/class-metrics?facility=all'
: '/api/dashboard/class-metrics'
!programsEnabled
? null
: deptAdmin
? '/api/dashboard/class-metrics?facility=all'
: '/api/dashboard/class-metrics'
);
const { data: missingAttendanceResp, isLoading: missingAttendanceLoading } =
useSWR<ServerResponseMany<MissingAttendanceItem>>(
deptAdmin
? '/api/program-classes/missing-attendance?facility=all&days=3&all=true'
: '/api/program-classes/missing-attendance?days=3&all=true'
!programsEnabled
? null
: deptAdmin
? '/api/program-classes/missing-attendance?facility=all&days=3&all=true'
: '/api/program-classes/missing-attendance?days=3&all=true'
);
const { data: facilityHealthResp } = useSWR<
ServerResponseOne<FacilityHealthSummary[]>
>(
deptAdmin
? '/api/dashboard/facility-health?facility=all&days=3'
: '/api/dashboard/facility-health?days=3'
!programsEnabled
? null
: deptAdmin
? '/api/dashboard/facility-health?facility=all&days=3'
: '/api/dashboard/facility-health?days=3'
);
const { data: todaysScheduleResp, isLoading: todaysScheduleLoading } =
useSWR<ServerResponseMany<TodaysScheduleItem>>(
deptAdmin
? '/api/program-classes/todays-schedule?facility=all&all=true'
: '/api/program-classes/todays-schedule?all=true'
!programsEnabled
? null
: deptAdmin
? '/api/program-classes/todays-schedule?facility=all&all=true'
: '/api/program-classes/todays-schedule?all=true'
);
const todaysScheduleItems = useMemo<TodaysScheduleItem[]>(
() => todaysScheduleResp?.data ?? ([] as TodaysScheduleItem[]),
Expand All @@ -85,13 +96,15 @@ export default function Dashboard() {
<div className="max-w-7xl mx-auto px-6 py-8">
{deptAdmin ? (
<DeptAdminView
programsEnabled={programsEnabled}
metrics={classMetricsResp?.data}
missingAttendance={missingAttendanceResp?.data ?? []}
missingAttendanceLoading={missingAttendanceLoading}
facilityHealthSummaries={facilityHealthResp?.data ?? []}
/>
) : (
<FacilityAdminView
programsEnabled={programsEnabled}
facilityName={user?.facility.name ?? 'My Facility'}
metrics={classMetricsResp?.data}
missingAttendance={missingAttendanceResp?.data ?? []}
Expand All @@ -106,13 +119,15 @@ export default function Dashboard() {
}

function FacilityAdminView({
programsEnabled,
facilityName,
metrics,
missingAttendance,
missingAttendanceLoading,
todaysScheduleItems,
todaysScheduleLoading
}: {
programsEnabled: boolean;
facilityName: string;
metrics?: ClassMetrics;
missingAttendance: MissingAttendanceItem[];
Expand All @@ -134,13 +149,26 @@ function FacilityAdminView({
);
};

const header = (
<PageHeader
title="Facility Dashboard"
subtitle={facilityName}
className="mb-[15px]"
/>
);

if (!programsEnabled) {
return (
<div>
{header}
<ProgramsOffNotice />
</div>
);
}

return (
<div>
<PageHeader
title="Facility Dashboard"
subtitle={facilityName}
className="mb-[15px]"
/>
{header}

<MetricCards stats={stats} onNavigate={navigate} />

Expand All @@ -166,11 +194,13 @@ function FacilityAdminView({
}

function DeptAdminView({
programsEnabled,
metrics,
missingAttendance,
missingAttendanceLoading,
facilityHealthSummaries
}: {
programsEnabled: boolean;
metrics?: ClassMetrics;
missingAttendance: MissingAttendanceItem[];
missingAttendanceLoading: boolean;
Expand All @@ -186,9 +216,22 @@ function DeptAdminView({
[facilityHealthSummaries]
);

const header = (
<PageHeader title="Department Overview" className="mb-[15px]" />
);

if (!programsEnabled) {
return (
<div>
{header}
<ProgramsOffNotice />
</div>
);
}

return (
<div>
<PageHeader title="Department Overview" className="mb-[15px]" />
{header}

<MetricCards stats={stats} onNavigate={navigate} />

Expand Down Expand Up @@ -216,6 +259,14 @@ function DeptAdminView({
);
}

function ProgramsOffNotice() {
return (
<p className="text-sm text-gray-600 dark:text-gray-400">
Programs isn't turned on for this facility.
</p>
);
}

interface MetricCardProps {
icon: React.ReactNode;
iconBg: string;
Expand Down
43 changes: 27 additions & 16 deletions frontend/src/pages/insights/OperationalInsights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import {
SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuth, canSwitchFacility } from '@/auth/useAuth';
import { Facility, InsightsRangeKey, ServerResponseMany } from '@/types';
import { useAuth, canSwitchFacility, hasFeature } from '@/auth/useAuth';
import {
Facility,
FeatureAccess,
InsightsRangeKey,
ServerResponseMany
} from '@/types';
import OverviewTab from './OverviewTab';
import KnowledgeCenterTab from './KnowledgeCenterTab';
import ProgramsTab from './ProgramsTab';
Expand All @@ -23,6 +28,8 @@ const TAB_TRIGGER_CLASS =
export default function OperationalInsightsPage() {
const { user } = useAuth();
const canSwitch = user ? canSwitchFacility(user) : false;
const programsEnabled =
!!user && hasFeature(user, FeatureAccess.ProgramAccess);

const [activeRange, setActiveRange] = useState<InsightsRangeKey>('30D');
const [customFrom, setCustomFrom] = useState('');
Expand Down Expand Up @@ -156,12 +163,14 @@ export default function OperationalInsightsPage() {
>
Knowledge Center
</TabsTrigger>
<TabsTrigger
value="programs"
className={TAB_TRIGGER_CLASS}
>
Programs
</TabsTrigger>
{programsEnabled && (
<TabsTrigger
value="programs"
className={TAB_TRIGGER_CLASS}
>
Programs
</TabsTrigger>
)}
</TabsList>
<TabsContent value="overview" className="mt-0">
<OverviewTab
Expand All @@ -178,14 +187,16 @@ export default function OperationalInsightsPage() {
rangeLabel={rangeLabel}
/>
</TabsContent>
<TabsContent value="programs" className="mt-0">
<ProgramsTab
dateParams={dateParams}
selectedFacility={selectedFacility}
rangeLabel={rangeLabel}
scopeLabel={scopeLabel}
/>
</TabsContent>
{programsEnabled && (
<TabsContent value="programs" className="mt-0">
<ProgramsTab
dateParams={dateParams}
selectedFacility={selectedFacility}
rangeLabel={rangeLabel}
scopeLabel={scopeLabel}
/>
</TabsContent>
)}
</Tabs>
</SWRConfig>
</div>
Expand Down
Loading