From 934e832b51f059b2a6c9a760a9be6731e87ce444 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Wed, 15 Oct 2025 15:46:28 -0700 Subject: [PATCH] Add error handling and notes page. --- app/jobs/[jobId]/_layout.tsx | 6 +- app/jobs/[jobId]/documents.tsx | 9 +- app/jobs/[jobId]/notes.tsx | 9 +- app/jobs/_layout.tsx | 4 +- .../media-cache-overlay.component.jsx | 55 ------ components/error/error-display.jsx | 21 ++- components/job-documents/job-documents.jsx | 170 ++++++++++++++++++ components/job-lines/job-lines.jsx | 26 +-- components/job-notes/job-notes.jsx | 100 +++++++++++ components/job-tombstone/job-tombstone.jsx | 20 ++- translations/en-US/common.json | 3 +- 11 files changed, 329 insertions(+), 94 deletions(-) create mode 100644 components/job-documents/job-documents.jsx create mode 100644 components/job-notes/job-notes.jsx diff --git a/app/jobs/[jobId]/_layout.tsx b/app/jobs/[jobId]/_layout.tsx index 614c461..8f50721 100644 --- a/app/jobs/[jobId]/_layout.tsx +++ b/app/jobs/[jobId]/_layout.tsx @@ -30,7 +30,7 @@ function JobTabLayout(props) { options={{ title: t("jobdetail.labels.lines"), tabBarIcon: ({ color }) => ( - + ), }} /> @@ -39,7 +39,7 @@ function JobTabLayout(props) { options={{ title: t("jobdetail.labels.documents"), tabBarIcon: ({ color }) => ( - + ), }} /> @@ -48,7 +48,7 @@ function JobTabLayout(props) { options={{ title: t("jobdetail.labels.notes"), tabBarIcon: ({ color }) => ( - + ), }} /> diff --git a/app/jobs/[jobId]/documents.tsx b/app/jobs/[jobId]/documents.tsx index a00269b..71827d0 100644 --- a/app/jobs/[jobId]/documents.tsx +++ b/app/jobs/[jobId]/documents.tsx @@ -1,10 +1,5 @@ -import { Text, View } from "react-native"; - +import JobDocuments from "../../../components/job-documents/job-documents"; function Documents() { - return ( - - Documents - - ); + return ; } export default Documents; diff --git a/app/jobs/[jobId]/notes.tsx b/app/jobs/[jobId]/notes.tsx index 38b41c0..6625331 100644 --- a/app/jobs/[jobId]/notes.tsx +++ b/app/jobs/[jobId]/notes.tsx @@ -1,10 +1,5 @@ -import { Text, View } from "react-native"; - +import JobNotes from "../../../components/job-notes/job-notes"; function Notes() { - return ( - - Notes - - ); + return ; } export default Notes; diff --git a/app/jobs/_layout.tsx b/app/jobs/_layout.tsx index a34081f..72168bf 100644 --- a/app/jobs/_layout.tsx +++ b/app/jobs/_layout.tsx @@ -1,8 +1,9 @@ import { Stack, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; function JobsStack() { const router = useRouter(); - + const { t } = useTranslation(); return ( ); - - // return ( - // setPreviewVisible(false)} - // onRequestClose={() => setPreviewVisible(false)} - // visible={previewVisible} - // transparent={false} - // > - // - // setcurrentIndex(position)} - // onPageScrollStateChanged={(state) => - // state === "idle" ? setDragging(false) : setDragging(true) - // } - // /> - // setPreviewVisible(false)} - // > - // - // - // {!dragging && photos[currentIndex] && photos[currentIndex].videoUrl && ( - // { - // await videoRef.current.loadAsync( - // { uri: photos[currentIndex].videoUrl }, - // {}, - // false - // ); - // videoRef.current.presentFullscreenPlayer(); - // }} - // > - // - // - // )} - // - // - // ); -} diff --git a/components/error/error-display.jsx b/components/error/error-display.jsx index df4b749..105af95 100644 --- a/components/error/error-display.jsx +++ b/components/error/error-display.jsx @@ -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 ( - - {errorMessage} - + + + + + {errorMessage || + error?.message || + error || + "An unknown error has occured."} + + + ); } diff --git a/components/job-documents/job-documents.jsx b/components/job-documents/job-documents.jsx new file mode 100644 index 0000000..d8566b4 --- /dev/null +++ b/components/job-documents/job-documents.jsx @@ -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 ; + } + + return ( + + + } + data={fullphotos} + ListFooterComponent={ + {`${ + fullphotos.length + } document${fullphotos.length === 1 ? "" : "s"}`} + } + numColumns={4} + style={{ flex: 1 }} + keyExtractor={(item) => item.id} + renderItem={(object) => ( + { + setImgIndex(object.index); + setPreviewVisible(true); + }} + > + + + )} + /> + + setPreviewVisible(false)} + visible={previewVisible} + images={fullphotos} + imageIndex={imgIndex} + swipeToCloseEnabled={true} + /> + + ); +} diff --git a/components/job-lines/job-lines.jsx b/components/job-lines/job-lines.jsx index 5a6d7bf..7899086 100644 --- a/components/job-lines/job-lines.jsx +++ b/components/job-lines/job-lines.jsx @@ -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 ; } - if (!data?.jobs_by_pk) { - return ( - - Job is not defined. - - ); - } - const job = data.jobs_by_pk; + if (error) { + return ; + } + + if (!data?.jobs_by_pk) { + return ; + } + + const job = data.jobs_by_pk; return ( diff --git a/components/job-notes/job-notes.jsx b/components/job-notes/job-notes.jsx new file mode 100644 index 0000000..eac52e5 --- /dev/null +++ b/components/job-notes/job-notes.jsx @@ -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 ; + } + if (error) { + return ; + } + + if (!data?.jobs_by_pk) { + return ; + } + const job = data.jobs_by_pk; + + if (job.notes.length === 0) + return ( + + + {t("jobdetail.labels.nojobnotes")} + + + ); + + return ( + + } + style={{ flex: 1 }} + data={job.notes} + renderItem={(object) => } + /> + ); +} + +function NoteListItem({ item }) { + return ( + + + + {item.text} + + {item.private && ( + + )} + {item.critical && ( + + )} + {item.created_by} + + {DateTime.fromISO(item.created_at).toLocaleString( + DateTime.DATETIME_SHORT + )} + + + + + + ); +} diff --git a/components/job-tombstone/job-tombstone.jsx b/components/job-tombstone/job-tombstone.jsx index b9b9aa0..78abc0a 100644 --- a/components/job-tombstone/job-tombstone.jsx +++ b/components/job-tombstone/job-tombstone.jsx @@ -48,7 +48,10 @@ export default function JobTombstone() { } > - + {job.status} {job.inproduction && ( @@ -63,7 +66,10 @@ export default function JobTombstone() { - + - + - +