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

@@ -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