Add error handling and notes page.

This commit is contained in:
Patrick Fic
2025-10-15 15:46:28 -07:00
parent 8d60d9776c
commit 934e832b51
11 changed files with 329 additions and 94 deletions

View File

@@ -30,7 +30,7 @@ function JobTabLayout(props) {
options={{
title: t("jobdetail.labels.lines"),
tabBarIcon: ({ color }) => (
<FontAwesome size={28} name="cog" color={color} />
<FontAwesome size={28} name="list" color={color} />
),
}}
/>
@@ -39,7 +39,7 @@ function JobTabLayout(props) {
options={{
title: t("jobdetail.labels.documents"),
tabBarIcon: ({ color }) => (
<FontAwesome size={28} name="cog" color={color} />
<FontAwesome size={28} name="photo" color={color} />
),
}}
/>
@@ -48,7 +48,7 @@ function JobTabLayout(props) {
options={{
title: t("jobdetail.labels.notes"),
tabBarIcon: ({ color }) => (
<FontAwesome size={28} name="cog" color={color} />
<FontAwesome size={28} name="sticky-note" color={color} />
),
}}
/>

View File

@@ -1,10 +1,5 @@
import { Text, View } from "react-native";
import JobDocuments from "../../../components/job-documents/job-documents";
function Documents() {
return (
<View>
<Text>Documents</Text>
</View>
);
return <JobDocuments />;
}
export default Documents;

View File

@@ -1,10 +1,5 @@
import { Text, View } from "react-native";
import JobNotes from "../../../components/job-notes/job-notes";
function Notes() {
return (
<View>
<Text>Notes</Text>
</View>
);
return <JobNotes />;
}
export default Notes;

View File

@@ -1,8 +1,9 @@
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
function JobsStack() {
const router = useRouter();
const { t } = useTranslation();
return (
<Stack
screenOptions={{
@@ -15,6 +16,7 @@ function JobsStack() {
name="index"
options={{
headerShown: false,
title: t("joblist.titles.jobtab"),
// headerSearchBarOptions: {
// placement: "automatic",
// placeholder: "Search",

View File

@@ -25,58 +25,3 @@ export default function MediaCacheOverlay({
/>
</SafeAreaView>
);
// return (
// <Modal
// onDismiss={() => setPreviewVisible(false)}
// onRequestClose={() => setPreviewVisible(false)}
// visible={previewVisible}
// transparent={false}
// >
// <SafeAreaView style={{ flex: 1, backgroundColor: "black" }}>
// <Gallery
// initialPage={imgIndex}
// images={photos}
// onPageScroll={({ position }) => setcurrentIndex(position)}
// onPageScrollStateChanged={(state) =>
// state === "idle" ? setDragging(false) : setDragging(true)
// }
// />
// <TouchableOpacity
// style={{ position: "absolute" }}
// onPress={() => setPreviewVisible(false)}
// >
// <Ionicons
// name="ios-close"
// size={64}
// color="dodgerblue"
// style={{ margin: 20 }}
// />
// </TouchableOpacity>
// {!dragging && photos[currentIndex] && photos[currentIndex].videoUrl && (
// <TouchableOpacity
// style={{
// position: "absolute",
// left: Dimensions.get("window").width / 2 - 32,
// top: Dimensions.get("window").height / 2 - 32,
// justifyContent: "center",
// alignItems: "center",
// }}
// onPress={async () => {
// await videoRef.current.loadAsync(
// { uri: photos[currentIndex].videoUrl },
// {},
// false
// );
// videoRef.current.presentFullscreenPlayer();
// }}
// >
// <Ionicons name="play" size={64} color="white" />
// </TouchableOpacity>
// )}
// </SafeAreaView>
// <Video ref={videoRef} useNativeControls />
// </Modal>
// );
}

View File

@@ -1,9 +1,20 @@
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { Card } from "react-native-paper";
export default function ErrorDisplay({ errorMessage }) {
export default function ErrorDisplay({ errorMessage, error }) {
const { t } = useTranslation();
return (
<View style={{ backgroundColor: "red" }}>
<Text>{errorMessage}</Text>
</View>
<Card style={{ margin: 8, backgroundColor: "#ffdddd" }}>
<Card.Title title={t("general.labels.error")} titleVariant="titleLarge" />
<Card.Content>
<Text>
{errorMessage ||
error?.message ||
error ||
"An unknown error has occured."}
</Text>
</Card.Content>
</Card>
);
}

View File

@@ -0,0 +1,170 @@
import cleanAxios from "@/util/CleanAxios";
import axios from "axios";
import { useGlobalSearchParams } from "expo-router";
import React, { useCallback, useEffect, useState } from "react";
import {
FlatList,
Image,
RefreshControl,
Text,
TouchableOpacity,
View,
} from "react-native";
import ImageView from "react-native-image-viewing";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import env from "../../env";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { DetermineFileType } from "../../util/document-upload.utility";
import ErrorDisplay from "../error/error-display";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(
mapStateToProps,
mapDispatchToProps
)(JobDocumentsComponent);
export function JobDocumentsComponent({ bodyshop }) {
const [previewVisible, setPreviewVisible] = useState(false);
const [fullphotos, setFullPhotos] = useState([]);
const [imgIndex, setImgIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const { jobId } = useGlobalSearchParams();
const isLms = bodyshop.uselocalmediaserver;
const onRefresh = async () => {
return getPhotos();
};
const getPhotos = useCallback(async () => {
setError(null);
setLoading(true);
try {
if (!isLms) {
const result = await axios.post(
`${env.API_URL}/media/imgproxy/thumbnails`,
{
jobid: jobId,
}
);
setFullPhotos(
result.data.map((doc, idx) => {
return {
id: idx,
videoUrl:
DetermineFileType(doc.type) === "video" &&
doc.originalUrlViaProxyPath,
source:
DetermineFileType(doc.type) === "video"
? { uri: doc.thumbnailUrl }
: { uri: doc.originalUrl },
url:
DetermineFileType(doc.type) === "video"
? doc.thumbnailUrl
: doc.originalUrl,
uri:
DetermineFileType(doc.type) === "video"
? doc.originalUrlViaProxyPath
: doc.originalUrl,
thumbUrl: doc.thumbnailUrl,
};
})
);
} else {
let localmediaserverhttp = bodyshop.localmediaserverhttp.trim();
if (localmediaserverhttp.endsWith("/")) {
localmediaserverhttp = localmediaserverhttp.slice(0, -1);
}
const imagesFetch = await cleanAxios.post(
`${localmediaserverhttp}/jobs/list`,
{
jobid: jobId,
},
{ headers: { ims_token: bodyshop.localmediatoken } }
);
const normalizedImages = imagesFetch.data
.filter((d) => d.type?.mime?.startsWith("image"))
.map((d, idx) => {
return {
...d,
// src: `${localmediaserverhttp}/${d.src}`,
uri: `${localmediaserverhttp}${d.src}`,
thumbUrl: `${localmediaserverhttp}${d.thumbnail}`,
id: idx,
};
});
setFullPhotos(normalizedImages);
}
} catch (error) {
console.log(
"Error fetching photos:",
error.message,
JSON.stringify(error, null, 2)
);
setError(error.message || "Unknown error fetching photos.");
}
setLoading(false);
}, [isLms, jobId, bodyshop]);
useEffect(() => {
getPhotos();
}, [getPhotos]);
if (error) {
return <ErrorDisplay message={JSON.stringify(error)} />;
}
return (
<View style={{ flex: 1 }}>
<FlatList
refreshControl={
<RefreshControl refreshing={loading} onRefresh={onRefresh} />
}
data={fullphotos}
ListFooterComponent={
<Text style={{ textAlign: "center", padding: 8 }}>{`${
fullphotos.length
} document${fullphotos.length === 1 ? "" : "s"}`}</Text>
}
numColumns={4}
style={{ flex: 1 }}
keyExtractor={(item) => item.id}
renderItem={(object) => (
<TouchableOpacity
style={{ flex: 1 / 4, aspectRatio: 1, margin: 4 }}
onPress={async () => {
setImgIndex(object.index);
setPreviewVisible(true);
}}
>
<Image
style={{ flex: 1 }}
resizeMode="cover"
source={{
uri: object.item.thumbUrl,
aspectRatio: 1,
}}
/>
</TouchableOpacity>
)}
/>
<ImageView
onRequestClose={() => setPreviewVisible(false)}
visible={previewVisible}
images={fullphotos}
imageIndex={imgIndex}
swipeToCloseEnabled={true}
/>
</View>
);
}

View File

@@ -3,10 +3,11 @@ import { useQuery } from "@apollo/client";
import { useGlobalSearchParams } from "expo-router";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, Text } from "react-native";
import { ActivityIndicator, Card, DataTable } from "react-native-paper";
import { ScrollView } from "react-native";
import { ActivityIndicator, DataTable } from "react-native-paper";
import ErrorDisplay from "../error/error-display";
export default function JobLines(props) {
export default function JobLines() {
const { jobId } = useGlobalSearchParams();
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
@@ -16,22 +17,25 @@ export default function JobLines(props) {
skip: !jobId,
});
console.log("*** ~ JobLines ~ error:", error);
const { t } = useTranslation();
const onRefresh = async () => {
return refetch();
};
if (loading) {
return <ActivityIndicator />;
}
if (!data?.jobs_by_pk) {
return (
<Card>
<Text>Job is not defined.</Text>
</Card>
);
}
const job = data.jobs_by_pk;
if (error) {
return <ErrorDisplay message={JSON.stringify(error)} />;
}
if (!data?.jobs_by_pk) {
return <ErrorDisplay message={"Job is not defined."} />;
}
const job = data.jobs_by_pk;
return (
<ScrollView>
<DataTable>

View File

@@ -0,0 +1,100 @@
import { GET_JOB_BY_PK } from "@/graphql/jobs.queries";
import { useQuery } from "@apollo/client";
import { AntDesign } from "@expo/vector-icons";
import { useGlobalSearchParams } from "expo-router";
import { DateTime } from "luxon";
import React from "react";
import { useTranslation } from "react-i18next";
import { FlatList, RefreshControl, Text, View } from "react-native";
import { ActivityIndicator, Card } from "react-native-paper";
import ErrorDisplay from "../error/error-display";
export default function JobNotes() {
const { jobId } = useGlobalSearchParams();
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: {
id: jobId,
},
skip: !jobId,
});
const { t } = useTranslation();
const onRefresh = async () => {
return refetch();
};
if (loading) {
return <ActivityIndicator />;
}
if (error) {
return <ErrorDisplay message={JSON.stringify(error?.message)} />;
}
if (!data?.jobs_by_pk) {
return <ErrorDisplay errorMessage={"Job is not defined."} />;
}
const job = data.jobs_by_pk;
if (job.notes.length === 0)
return (
<Card>
<Card.Content>
<Text>{t("jobdetail.labels.nojobnotes")}</Text>
</Card.Content>
</Card>
);
return (
<FlatList
refreshControl={
<RefreshControl refreshing={loading} onRefresh={onRefresh} />
}
style={{ flex: 1 }}
data={job.notes}
renderItem={(object) => <NoteListItem item={object.item} />}
/>
);
}
function NoteListItem({ item }) {
return (
<Card style={{ margin: 8 }}>
<Card.Content>
<View style={{ display: "flex", flex: 1 }}>
<Text>{item.text}</Text>
<View
style={{
flexDirection: "column",
alignSelf: "flex-end",
alignItems: "center",
}}
>
{item.private && (
<AntDesign
name="eyeo"
style={{ margin: 4 }}
size={24}
color="black"
/>
)}
{item.critical && (
<AntDesign
name="warning"
style={{ margin: 4 }}
size={24}
color="tomato"
/>
)}
<Text style={{ fontSize: 12 }}>{item.created_by}</Text>
<Text style={{ fontSize: 12 }}>
{DateTime.fromISO(item.created_at).toLocaleString(
DateTime.DATETIME_SHORT
)}
</Text>
</View>
</View>
</Card.Content>
</Card>
);
}

View File

@@ -48,7 +48,10 @@ export default function JobTombstone() {
}
>
<Card>
<Card.Title title={t("jobdetail.labels.jobinfo")} />
<Card.Title
title={t("jobdetail.labels.jobinfo")}
titleVariant="titleLarge"
/>
<Card.Content>
<Text>{job.status}</Text>
{job.inproduction && (
@@ -63,7 +66,10 @@ export default function JobTombstone() {
</Card>
<Card>
<Card.Title title={t("jobdetail.labels.claiminformation")} />
<Card.Title
title={t("jobdetail.labels.claiminformation")}
titleVariant="titleLarge"
/>
<Card.Content style={localStyles.twoColumnCard}>
<View style={localStyles.twoColumnCardColumn}>
<DataLabelComponent
@@ -97,7 +103,10 @@ export default function JobTombstone() {
</Card.Content>
</Card>
<Card>
<Card.Title title={t("jobdetail.labels.employeeassignments")} />
<Card.Title
title={t("jobdetail.labels.employeeassignments")}
titleVariant="titleLarge"
/>
<Card.Content>
<DataLabelComponent
label={t("objects.jobs.fields.employee_body")}
@@ -138,7 +147,10 @@ export default function JobTombstone() {
</Card.Content>
</Card>
<Card>
<Card.Title title={t("jobdetail.labels.dates")} />
<Card.Title
title={t("jobdetail.labels.dates")}
titleVariant="titleLarge"
/>
<Card.Content style={localStyles.twoColumnCard}>
<View style={localStyles.twoColumnCardColumn}>
<DataLabelComponent

View File

@@ -14,7 +14,8 @@
"signout": "Sign Out"
},
"labels": {
"na": "N/A"
"na": "N/A",
"error": "Error"
}
},
"jobdetail": {