mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Implement On-Call policies feature with navigation, API integration, and UI components
This commit is contained in:
22
MobileApp/src/api/onCallPolicies.ts
Normal file
22
MobileApp/src/api/onCallPolicies.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import apiClient from "./client";
|
||||
import type { CurrentOnDutyEscalationPoliciesResponse } from "./types";
|
||||
|
||||
export async function fetchCurrentOnDutyEscalationPolicies(
|
||||
projectId: string,
|
||||
): Promise<CurrentOnDutyEscalationPoliciesResponse> {
|
||||
const response: AxiosResponse = await apiClient.get(
|
||||
"/api/on-call-duty-policy/current-on-duty-escalation-policies",
|
||||
{
|
||||
headers: {
|
||||
tenantid: projectId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
escalationRulesByUser: response.data?.escalationRulesByUser ?? [],
|
||||
escalationRulesByTeam: response.data?.escalationRulesByTeam ?? [],
|
||||
escalationRulesBySchedule: response.data?.escalationRulesBySchedule ?? [],
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,11 @@ import type IncidentSeverityModel from "Common/Models/DatabaseModels/IncidentSev
|
||||
import type IncidentStateModelClass from "Common/Models/DatabaseModels/IncidentState.js";
|
||||
import type IncidentStateTimelineModel from "Common/Models/DatabaseModels/IncidentStateTimeline.js";
|
||||
import type MonitorModel from "Common/Models/DatabaseModels/Monitor.js";
|
||||
import type OnCallDutyPolicyModel from "Common/Models/DatabaseModels/OnCallDutyPolicy.js";
|
||||
import type OnCallDutyPolicyEscalationRuleModel from "Common/Models/DatabaseModels/OnCallDutyPolicyEscalationRule.js";
|
||||
import type OnCallDutyPolicyScheduleModel from "Common/Models/DatabaseModels/OnCallDutyPolicySchedule.js";
|
||||
import type ProjectModel from "Common/Models/DatabaseModels/Project.js";
|
||||
import type TeamModel from "Common/Models/DatabaseModels/Team.js";
|
||||
import type UserModel from "Common/Models/DatabaseModels/User.js";
|
||||
|
||||
type Alert = InstanceType<typeof AlertModel>;
|
||||
@@ -31,7 +35,13 @@ type IncidentSeverity = InstanceType<typeof IncidentSeverityModel>;
|
||||
type IncidentStateModel = InstanceType<typeof IncidentStateModelClass>;
|
||||
type IncidentStateTimeline = InstanceType<typeof IncidentStateTimelineModel>;
|
||||
type Monitor = InstanceType<typeof MonitorModel>;
|
||||
type OnCallDutyPolicy = InstanceType<typeof OnCallDutyPolicyModel>;
|
||||
type OnCallDutyPolicyEscalationRule = InstanceType<
|
||||
typeof OnCallDutyPolicyEscalationRuleModel
|
||||
>;
|
||||
type OnCallDutyPolicySchedule = InstanceType<typeof OnCallDutyPolicyScheduleModel>;
|
||||
type Project = InstanceType<typeof ProjectModel>;
|
||||
type Team = InstanceType<typeof TeamModel>;
|
||||
type User = InstanceType<typeof UserModel>;
|
||||
|
||||
type RequiredModelFields<T, K extends keyof T> = {
|
||||
@@ -232,3 +242,67 @@ export type ProjectIncidentItem = WithProject<IncidentItem>;
|
||||
export type ProjectAlertItem = WithProject<AlertItem>;
|
||||
export type ProjectIncidentEpisodeItem = WithProject<IncidentEpisodeItem>;
|
||||
export type ProjectAlertEpisodeItem = WithProject<AlertEpisodeItem>;
|
||||
|
||||
interface OnCallPolicyRef
|
||||
extends RequiredModelFields<OnCallDutyPolicy, "name"> {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface OnCallEscalationRuleRef
|
||||
extends RequiredModelFields<OnCallDutyPolicyEscalationRule, "name"> {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface TeamRef extends RequiredModelFields<Team, "name"> {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface OnCallScheduleRef
|
||||
extends RequiredModelFields<OnCallDutyPolicySchedule, "name"> {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface OnCallDutyEscalationRuleUserItem {
|
||||
onCallDutyPolicy?: OnCallPolicyRef;
|
||||
onCallDutyPolicyEscalationRule?: OnCallEscalationRuleRef;
|
||||
}
|
||||
|
||||
export interface OnCallDutyEscalationRuleTeamItem {
|
||||
onCallDutyPolicy?: OnCallPolicyRef;
|
||||
onCallDutyPolicyEscalationRule?: OnCallEscalationRuleRef;
|
||||
team?: TeamRef;
|
||||
}
|
||||
|
||||
export interface OnCallDutyEscalationRuleScheduleItem {
|
||||
onCallDutyPolicy?: OnCallPolicyRef;
|
||||
onCallDutyPolicyEscalationRule?: OnCallEscalationRuleRef;
|
||||
onCallDutyPolicySchedule?: OnCallScheduleRef;
|
||||
}
|
||||
|
||||
export interface CurrentOnDutyEscalationPoliciesResponse {
|
||||
escalationRulesByUser: OnCallDutyEscalationRuleUserItem[];
|
||||
escalationRulesByTeam: OnCallDutyEscalationRuleTeamItem[];
|
||||
escalationRulesBySchedule: OnCallDutyEscalationRuleScheduleItem[];
|
||||
}
|
||||
|
||||
export type OnCallAssignmentType = "user" | "team" | "schedule";
|
||||
|
||||
export interface OnCallAssignmentItem {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
policyId?: string;
|
||||
policyName: string;
|
||||
escalationRuleName: string;
|
||||
assignmentType: OnCallAssignmentType;
|
||||
assignmentDetail: string;
|
||||
}
|
||||
|
||||
export interface ProjectOnCallAssignments {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
assignments: OnCallAssignmentItem[];
|
||||
}
|
||||
|
||||
142
MobileApp/src/hooks/useAllProjectOnCallPolicies.ts
Normal file
142
MobileApp/src/hooks/useAllProjectOnCallPolicies.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { useProject } from "./useProject";
|
||||
import { fetchCurrentOnDutyEscalationPolicies } from "../api/onCallPolicies";
|
||||
import type {
|
||||
CurrentOnDutyEscalationPoliciesResponse,
|
||||
OnCallAssignmentItem,
|
||||
ProjectItem,
|
||||
ProjectOnCallAssignments,
|
||||
} from "../api/types";
|
||||
|
||||
interface UseAllProjectOnCallPoliciesResult {
|
||||
projects: ProjectOnCallAssignments[];
|
||||
totalAssignments: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getEntityId(entity?: { _id?: string; id?: string }): string | undefined {
|
||||
return entity?._id ?? entity?.id;
|
||||
}
|
||||
|
||||
function toAssignments(
|
||||
project: ProjectItem,
|
||||
response: CurrentOnDutyEscalationPoliciesResponse,
|
||||
): OnCallAssignmentItem[] {
|
||||
const assignments: OnCallAssignmentItem[] = [];
|
||||
|
||||
response.escalationRulesByUser.forEach((rule) => {
|
||||
assignments.push({
|
||||
projectId: project._id,
|
||||
projectName: project.name,
|
||||
policyId: getEntityId(rule.onCallDutyPolicy),
|
||||
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
|
||||
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
|
||||
assignmentType: "user",
|
||||
assignmentDetail: "You are directly assigned",
|
||||
});
|
||||
});
|
||||
|
||||
response.escalationRulesByTeam.forEach((rule) => {
|
||||
assignments.push({
|
||||
projectId: project._id,
|
||||
projectName: project.name,
|
||||
policyId: getEntityId(rule.onCallDutyPolicy),
|
||||
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
|
||||
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
|
||||
assignmentType: "team",
|
||||
assignmentDetail: `Via team: ${rule.team?.name ?? "Unknown"}`,
|
||||
});
|
||||
});
|
||||
|
||||
response.escalationRulesBySchedule.forEach((rule) => {
|
||||
assignments.push({
|
||||
projectId: project._id,
|
||||
projectName: project.name,
|
||||
policyId: getEntityId(rule.onCallDutyPolicy),
|
||||
policyName: rule.onCallDutyPolicy?.name ?? "Unknown policy",
|
||||
escalationRuleName: rule.onCallDutyPolicyEscalationRule?.name ?? "Unknown rule",
|
||||
assignmentType: "schedule",
|
||||
assignmentDetail: `Via schedule: ${rule.onCallDutyPolicySchedule?.name ?? "Unknown"}`,
|
||||
});
|
||||
});
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
export function useAllProjectOnCallPolicies(): UseAllProjectOnCallPoliciesResult {
|
||||
const { projectList } = useProject();
|
||||
|
||||
const query: UseQueryResult<ProjectOnCallAssignments[], Error> = useQuery({
|
||||
queryKey: [
|
||||
"oncall",
|
||||
"current-duty",
|
||||
projectList
|
||||
.map((project: ProjectItem) => {
|
||||
return project._id;
|
||||
})
|
||||
.sort()
|
||||
.join(","),
|
||||
],
|
||||
enabled: projectList.length > 0,
|
||||
queryFn: async () => {
|
||||
const results: PromiseSettledResult<ProjectOnCallAssignments | null>[] =
|
||||
await Promise.allSettled(
|
||||
projectList.map(async (project: ProjectItem) => {
|
||||
const response: CurrentOnDutyEscalationPoliciesResponse =
|
||||
await fetchCurrentOnDutyEscalationPolicies(project._id);
|
||||
|
||||
const assignments: OnCallAssignmentItem[] = toAssignments(
|
||||
project,
|
||||
response,
|
||||
);
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: project._id,
|
||||
projectName: project.name,
|
||||
assignments,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const projects: ProjectOnCallAssignments[] = [];
|
||||
|
||||
results.forEach((result: PromiseSettledResult<ProjectOnCallAssignments | null>) => {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
projects.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
return projects.sort((a: ProjectOnCallAssignments, b: ProjectOnCallAssignments) => {
|
||||
return a.projectName.localeCompare(b.projectName);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const totalAssignments: number = useMemo(() => {
|
||||
return (query.data ?? []).reduce(
|
||||
(total: number, project: ProjectOnCallAssignments) => {
|
||||
return total + project.assignments.length;
|
||||
},
|
||||
0,
|
||||
);
|
||||
}, [query.data]);
|
||||
|
||||
const refetch: () => Promise<void> = async (): Promise<void> => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
return {
|
||||
projects: query.data ?? [],
|
||||
totalAssignments,
|
||||
isLoading: query.isPending,
|
||||
isError: query.isError,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { MainTabParamList } from "./types";
|
||||
import HomeScreen from "../screens/HomeScreen";
|
||||
import IncidentsStackNavigator from "./IncidentsStackNavigator";
|
||||
import AlertsStackNavigator from "./AlertsStackNavigator";
|
||||
import OnCallStackNavigator from "./OnCallStackNavigator";
|
||||
import SettingsStackNavigator from "./SettingsStackNavigator";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
@@ -166,6 +167,31 @@ export default function MainTabNavigator(): React.JSX.Element {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="OnCall"
|
||||
component={OnCallStackNavigator}
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "On-Call",
|
||||
tabBarIcon: ({
|
||||
color,
|
||||
focused,
|
||||
}: {
|
||||
color: string;
|
||||
focused: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<TabIcon
|
||||
name="call-outline"
|
||||
focusedName="call"
|
||||
color={color}
|
||||
focused={focused}
|
||||
accentColor={theme.colors.actionPrimary}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsStackNavigator}
|
||||
|
||||
39
MobileApp/src/navigation/OnCallStackNavigator.tsx
Normal file
39
MobileApp/src/navigation/OnCallStackNavigator.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { useTheme } from "../theme";
|
||||
import MyOnCallPoliciesScreen from "../screens/MyOnCallPoliciesScreen";
|
||||
import type { OnCallStackParamList } from "./types";
|
||||
|
||||
const Stack: ReturnType<typeof createNativeStackNavigator<OnCallStackParamList>> =
|
||||
createNativeStackNavigator<OnCallStackParamList>();
|
||||
|
||||
export default function OnCallStackNavigator(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
},
|
||||
headerTintColor: theme.colors.textPrimary,
|
||||
headerShadowVisible: false,
|
||||
...(Platform.OS === "ios"
|
||||
? {
|
||||
headerLargeTitle: true,
|
||||
headerLargeStyle: {
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="OnCallList"
|
||||
component={MyOnCallPoliciesScreen}
|
||||
options={{ title: "My On-Call Policies" }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,11 @@ const linking: React.ComponentProps<typeof NavigationContainer>["linking"] = {
|
||||
AlertEpisodeDetail: "alert-episode/:projectId/:episodeId",
|
||||
},
|
||||
},
|
||||
OnCall: {
|
||||
screens: {
|
||||
OnCallList: "on-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MainTabParamList = {
|
||||
Home: undefined;
|
||||
Incidents: undefined;
|
||||
Alerts: undefined;
|
||||
OnCall: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
@@ -14,6 +15,10 @@ export type SettingsStackParamList = {
|
||||
SettingsList: undefined;
|
||||
};
|
||||
|
||||
export type OnCallStackParamList = {
|
||||
OnCallList: undefined;
|
||||
};
|
||||
|
||||
export type IncidentsStackParamList = {
|
||||
IncidentsList: undefined;
|
||||
IncidentDetail: { incidentId: string; projectId: string };
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
|
||||
import type { MainTabParamList } from "../navigation/types";
|
||||
import Logo from "../components/Logo";
|
||||
import GradientButton from "../components/GradientButton";
|
||||
import { useAllProjectOnCallPolicies } from "../hooks/useAllProjectOnCallPolicies";
|
||||
|
||||
type HomeNavProp = BottomTabNavigationProp<MainTabParamList, "Home">;
|
||||
|
||||
@@ -144,11 +145,18 @@ export default function HomeScreen(): React.JSX.Element {
|
||||
refetch,
|
||||
} = useAllProjectCounts();
|
||||
|
||||
const {
|
||||
totalAssignments,
|
||||
projects: onCallProjects,
|
||||
isLoading: onCallLoading,
|
||||
refetch: refetchOnCall,
|
||||
} = useAllProjectOnCallPolicies();
|
||||
|
||||
const { lightImpact } = useHaptics();
|
||||
|
||||
const onRefresh: () => Promise<void> = async (): Promise<void> => {
|
||||
lightImpact();
|
||||
await Promise.all([refetch(), refreshProjects()]);
|
||||
await Promise.all([refetch(), refreshProjects(), refetchOnCall()]);
|
||||
};
|
||||
|
||||
if (!isLoadingProjects && projectList.length === 0) {
|
||||
@@ -324,6 +332,105 @@ export default function HomeScreen(): React.JSX.Element {
|
||||
</View>
|
||||
|
||||
<View className="gap-4 px-5">
|
||||
<View>
|
||||
<Text
|
||||
className="text-[12px] font-semibold uppercase mb-2"
|
||||
style={{
|
||||
color: theme.colors.textSecondary,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
On-Call
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
lightImpact();
|
||||
navigation.navigate("OnCall");
|
||||
}}
|
||||
className="rounded-3xl overflow-hidden p-4"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
shadowColor: theme.isDark ? "#000" : theme.colors.accentGradientMid,
|
||||
shadowOpacity: theme.isDark ? 0.24 : 0.09,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 14,
|
||||
elevation: 5,
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View my on-call assignments"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
theme.colors.oncallActiveBg,
|
||||
theme.colors.accentGradientEnd + "06",
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -20,
|
||||
left: -10,
|
||||
right: -10,
|
||||
height: 120,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center flex-1">
|
||||
<View
|
||||
className="w-10 h-10 rounded-2xl items-center justify-center mr-3"
|
||||
style={{ backgroundColor: theme.colors.oncallActiveBg }}
|
||||
>
|
||||
<Ionicons
|
||||
name="call-outline"
|
||||
size={18}
|
||||
color={theme.colors.oncallActive}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-[15px] font-bold"
|
||||
style={{ color: theme.colors.textPrimary }}
|
||||
>
|
||||
My On-Call Policies
|
||||
</Text>
|
||||
<Text
|
||||
className="text-[12px] mt-0.5"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
>
|
||||
{onCallLoading
|
||||
? "Loading assignments..."
|
||||
: totalAssignments > 0
|
||||
? `${totalAssignments} active ${totalAssignments === 1 ? "assignment" : "assignments"} across ${onCallProjects.length} ${onCallProjects.length === 1 ? "project" : "projects"}`
|
||||
: "You are not currently on-call"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="items-end ml-3">
|
||||
<Text
|
||||
className="text-[28px] font-bold"
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontVariant: ["tabular-nums"],
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{onCallLoading ? "--" : totalAssignments}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={14}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text
|
||||
className="text-[12px] font-semibold uppercase mb-2"
|
||||
|
||||
413
MobileApp/src/screens/MyOnCallPoliciesScreen.tsx
Normal file
413
MobileApp/src/screens/MyOnCallPoliciesScreen.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useTheme } from "../theme";
|
||||
import { useHaptics } from "../hooks/useHaptics";
|
||||
import { useAllProjectOnCallPolicies } from "../hooks/useAllProjectOnCallPolicies";
|
||||
import EmptyState from "../components/EmptyState";
|
||||
import SkeletonCard from "../components/SkeletonCard";
|
||||
import type {
|
||||
OnCallAssignmentItem,
|
||||
OnCallAssignmentType,
|
||||
ProjectOnCallAssignments,
|
||||
} from "../api/types";
|
||||
|
||||
interface AssignmentBadgeConfig {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
color: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
function getAssignmentBadge(
|
||||
type: OnCallAssignmentType,
|
||||
colors: {
|
||||
success: string;
|
||||
successBg: string;
|
||||
info: string;
|
||||
infoBg: string;
|
||||
warning: string;
|
||||
warningBg: string;
|
||||
},
|
||||
): AssignmentBadgeConfig {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return {
|
||||
icon: "person-outline",
|
||||
label: "Direct",
|
||||
color: colors.success,
|
||||
background: colors.successBg,
|
||||
};
|
||||
case "team":
|
||||
return {
|
||||
icon: "people-outline",
|
||||
label: "Team",
|
||||
color: colors.info,
|
||||
background: colors.infoBg,
|
||||
};
|
||||
case "schedule":
|
||||
return {
|
||||
icon: "time-outline",
|
||||
label: "Schedule",
|
||||
color: colors.warning,
|
||||
background: colors.warningBg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function MyOnCallPoliciesScreen(): React.JSX.Element {
|
||||
const { theme } = useTheme();
|
||||
const { lightImpact } = useHaptics();
|
||||
const { projects, totalAssignments, isLoading, isError, refetch } =
|
||||
useAllProjectOnCallPolicies();
|
||||
|
||||
const projectCount: number = projects.length;
|
||||
|
||||
const summaryText: string = useMemo(() => {
|
||||
const assignmentLabel: string =
|
||||
totalAssignments === 1 ? "assignment" : "assignments";
|
||||
const projectLabel: string = projectCount === 1 ? "project" : "projects";
|
||||
|
||||
return `You are currently on duty for ${totalAssignments} ${assignmentLabel} across ${projectCount} ${projectLabel}.`;
|
||||
}, [projectCount, totalAssignments]);
|
||||
|
||||
const onRefresh: () => Promise<void> = async (): Promise<void> => {
|
||||
lightImpact();
|
||||
await refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ backgroundColor: theme.colors.backgroundPrimary }}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: 44 }}>
|
||||
<View
|
||||
className="rounded-3xl overflow-hidden p-5 mb-4"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
}}
|
||||
>
|
||||
<SkeletonCard variant="compact" />
|
||||
</View>
|
||||
<SkeletonCard lines={3} />
|
||||
<SkeletonCard lines={4} />
|
||||
<SkeletonCard lines={3} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ backgroundColor: theme.colors.backgroundPrimary }}
|
||||
>
|
||||
<EmptyState
|
||||
title="Could not load on-call assignments"
|
||||
subtitle="Pull to refresh or try again."
|
||||
icon="alerts"
|
||||
actionLabel="Retry"
|
||||
onAction={() => {
|
||||
return refetch();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ backgroundColor: theme.colors.backgroundPrimary }}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 56 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.actionPrimary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View
|
||||
className="rounded-3xl overflow-hidden p-5 mb-5"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
shadowColor: theme.isDark ? "#000" : theme.colors.accentGradientMid,
|
||||
shadowOpacity: theme.isDark ? 0.32 : 0.1,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowRadius: 18,
|
||||
elevation: 7,
|
||||
}}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
theme.colors.oncallActiveBg,
|
||||
theme.colors.accentGradientEnd + "08",
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -70,
|
||||
left: -30,
|
||||
right: -20,
|
||||
height: 220,
|
||||
}}
|
||||
/>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center flex-1">
|
||||
<View
|
||||
className="w-11 h-11 rounded-2xl items-center justify-center mr-3"
|
||||
style={{
|
||||
backgroundColor: theme.colors.oncallActiveBg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="call-outline"
|
||||
size={20}
|
||||
color={theme.colors.oncallActive}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-[20px] font-bold"
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
letterSpacing: -0.4,
|
||||
}}
|
||||
>
|
||||
On-Call Now
|
||||
</Text>
|
||||
<Text
|
||||
className="text-[12px] mt-0.5"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
>
|
||||
Live duty assignments
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="px-3 py-1.5 rounded-xl"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="text-[18px] font-bold"
|
||||
style={{
|
||||
color: theme.colors.textPrimary,
|
||||
fontVariant: ["tabular-nums"],
|
||||
}}
|
||||
>
|
||||
{totalAssignments}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
className="text-[13px] mt-4 leading-5"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
>
|
||||
{summaryText}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<View
|
||||
className="rounded-3xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
title="Not currently on-call"
|
||||
subtitle="You are not on duty for any on-call policy right now."
|
||||
icon="alerts"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="gap-4">
|
||||
{projects.map((projectData: ProjectOnCallAssignments) => {
|
||||
return (
|
||||
<View
|
||||
key={projectData.projectId}
|
||||
className="rounded-3xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderGlass,
|
||||
shadowColor: theme.isDark
|
||||
? "#000"
|
||||
: theme.colors.accentGradientMid,
|
||||
shadowOpacity: theme.isDark ? 0.2 : 0.08,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowRadius: 14,
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="px-4 py-3.5 flex-row items-center justify-between"
|
||||
style={{
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderSubtle,
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center flex-1">
|
||||
<Ionicons
|
||||
name="folder-open-outline"
|
||||
size={16}
|
||||
color={theme.colors.textSecondary}
|
||||
/>
|
||||
<Text
|
||||
className="text-[14px] font-semibold ml-2 mr-2 flex-1"
|
||||
style={{ color: theme.colors.textPrimary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{projectData.projectName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="px-2 py-1 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: theme.colors.backgroundTertiary,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderSubtle,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="text-[11px] font-semibold"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
>
|
||||
{projectData.assignments.length} active
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
{projectData.assignments.map(
|
||||
(
|
||||
assignment: OnCallAssignmentItem,
|
||||
assignmentIndex: number,
|
||||
): React.JSX.Element => {
|
||||
const badge: AssignmentBadgeConfig = getAssignmentBadge(
|
||||
assignment.assignmentType,
|
||||
{
|
||||
success: theme.colors.oncallActive,
|
||||
successBg: theme.colors.oncallActiveBg,
|
||||
info: theme.colors.severityInfo,
|
||||
infoBg: theme.colors.severityInfoBg,
|
||||
warning: theme.colors.severityWarning,
|
||||
warningBg: theme.colors.severityWarningBg,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${assignment.projectId}-${assignment.policyId ?? assignmentIndex}`}
|
||||
activeOpacity={0.82}
|
||||
className="px-4 py-3.5"
|
||||
style={
|
||||
assignmentIndex !==
|
||||
projectData.assignments.length - 1
|
||||
? {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderSubtle,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text
|
||||
className="text-[15px] font-semibold flex-1 mr-3"
|
||||
style={{ color: theme.colors.textPrimary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{assignment.policyName}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
className="px-2.5 py-1 rounded-full flex-row items-center"
|
||||
style={{
|
||||
backgroundColor: badge.background,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={badge.icon}
|
||||
size={12}
|
||||
color={badge.color}
|
||||
/>
|
||||
<Text
|
||||
className="text-[11px] font-semibold ml-1"
|
||||
style={{ color: badge.color }}
|
||||
>
|
||||
{badge.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-2">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name="git-branch-outline"
|
||||
size={13}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
<Text
|
||||
className="text-[12px] ml-1.5"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Rule: {assignment.escalationRuleName}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Ionicons
|
||||
name="information-circle-outline"
|
||||
size={13}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
<Text
|
||||
className="text-[12px] ml-1.5"
|
||||
style={{ color: theme.colors.textSecondary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{assignment.assignmentDetail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user