diff --git a/babel-translations.babel b/babel-translations.babel index 61dc093..11c2b9d 100644 --- a/babel-translations.babel +++ b/babel-translations.babel @@ -126,6 +126,48 @@ actions + + add + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + cancel + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + signout false @@ -253,6 +295,32 @@ jobdetail + + actions + + + addnote + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + labels @@ -4883,6 +4951,278 @@ + + notes + + + fields + + + critical + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + pinned + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + private + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + text + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + types + + + customer + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + general + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + office + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + paint + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + parts + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + shop + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + supplement + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + + + labels + + + newnoteplaceholder + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + diff --git a/components/job-notes/job-notes.jsx b/components/job-notes/job-notes.jsx index e33a53e..e76a14f 100644 --- a/components/job-notes/job-notes.jsx +++ b/components/job-notes/job-notes.jsx @@ -3,14 +3,18 @@ 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 React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { FlatList, RefreshControl, View } from "react-native"; -import { ActivityIndicator, Card, Text } from "react-native-paper"; +import { ActivityIndicator, Button, Card, Text } from "react-native-paper"; import ErrorDisplay from "../error/error-display"; +import NewNoteModal from "./new-note-modal"; export default function JobNotes() { const { jobId } = useGlobalSearchParams(); + const [noteModalVisible, setNoteModalVisible] = useState(false); + const showNoteModal = () => setNoteModalVisible(true); + const hideNoteModal = () => setNoteModalVisible(false); const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, { variables: { @@ -19,6 +23,11 @@ export default function JobNotes() { skip: !jobId, }); + const handleNoteCreated = useCallback(() => { + hideNoteModal(); + refetch(); + }, [refetch]); + const { t } = useTranslation(); const onRefresh = async () => { return refetch(); @@ -36,24 +45,40 @@ export default function JobNotes() { } 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) => } - /> + + + {job.notes.length === 0 ? ( + + + {t("jobdetail.labels.nojobnotes")} + + + ) : ( + + } + style={{ flex: 1 }} + data={job.notes} + renderItem={(object) => } + /> + )} + + ); } @@ -72,7 +97,7 @@ function NoteListItem({ item }) { > {item.private && ( void - called when modal closed without save + * - onCreated: (note) => void - called after successful creation + * - relatedRos: array of job objects to optionally attach note to (excludes current job) + */ + +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser, +}); +const mapDispatchToProps = (dispatch) => ({}); + +const NewNoteModal = ({ + jobId, + visible, + onDismiss, + onCreated, + relatedRos = [], + existingNote, + currentUser, +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const [keyboardSpace, setKeyboardSpace] = useState(0); + + // Enable LayoutAnimation on Android + if ( + Platform.OS === "android" && + UIManager.setLayoutAnimationEnabledExperimental + ) { + try { + UIManager.setLayoutAnimationEnabledExperimental(true); + } catch (_) {} + } + + useEffect(() => { + const showSub = Keyboard.addListener("keyboardWillShow", (e) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setKeyboardSpace(e.endCoordinates.height); + }); + const hideSub = Keyboard.addListener("keyboardWillHide", () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setKeyboardSpace(0); + }); + // Fallback for Android (keyboardWillShow not always fired) + const showSubAndroid = Keyboard.addListener("keyboardDidShow", (e) => { + if (Platform.OS === "android") { + setKeyboardSpace(e.endCoordinates.height); + } + }); + const hideSubAndroid = Keyboard.addListener("keyboardDidHide", () => { + if (Platform.OS === "android") setKeyboardSpace(0); + }); + return () => { + showSub.remove(); + hideSub.remove(); + showSubAndroid.remove(); + hideSubAndroid.remove(); + }; + }, []); + + const [insertNote, { loading }] = useMutation(INSERT_NEW_NOTE, { + refetchQueries: [{ query: GET_JOB_BY_PK, variables: { id: jobId } }], + awaitRefetchQueries: true, + }); + + // Filter out current job id + const filteredRelatedRos = relatedRos.filter((j) => j.id !== jobId); + + const initialValues = { + critical: false, + private: false, + pinned: false, + type: "general", + text: "", + relatedros: {}, + }; + + const handleSubmit = async (values, helpers) => { + try { + const noteInput = { + jobid: jobId, + text: values.text.trim(), + critical: values.critical, + private: values.private, + pinned: values.pinned, + type: values.type, + created_by: currentUser?.email, + }; + console.log("*** ~ handleSubmit ~ noteInput:", noteInput); + // TODO: If backend supports attaching note to multiple related ROs, perform additional mutation(s) + // here after creating the base note. This may involve an insert into a join table like note_jobs. + // values.relatedros contains boolean flags keyed by job id. + const { data } = await insertNote({ + variables: { note: [noteInput] }, + }); + const created = data?.insert_notes?.returning?.[0]; + if (onCreated && created) onCreated(created); + helpers.resetForm(); + } catch (e) { + helpers.setStatus({ error: e.message }); + } + }; + + return ( + + + + { + const errors = {}; + if (!values.text || !values.text.trim()) { + errors.text = t("general.validation.required"); + } + return errors; + }} + onSubmit={handleSubmit} + > + {({ + values, + errors, + touched, + setFieldValue, + handleSubmit, + status, + }) => ( + + + {existingNote + ? t("objects.notes.labels.editnote") + : t("jobdetail.actions.addnote")} + + + + setFieldValue("critical", v)} + /> + setFieldValue("private", v)} + /> + setFieldValue("pinned", v)} + /> + + + {[ + "general", + "customer", + "shop", + "office", + "parts", + "paint", + "supplement", + ].map((option) => ( + setFieldValue("type", option)} + style={styles.chip} + > + {t(`objects.notes.fields.types.${option}`)} + + ))} + + setFieldValue("text", v)} + multiline + mode="outlined" + placeholder={t("objects.notes.labels.newnoteplaceholder")} + style={styles.textArea} + /> + {touched.text && errors.text && ( + + {errors.text} + + )} + {status?.error && ( + + {status.error} + + )} + {!existingNote && filteredRelatedRos.length > 0 && ( + + + {t("objects.notes.labels.addtorelatedro")} + + {filteredRelatedRos.map((j) => ( + + setFieldValue(`relatedros.${j.id}`, v) + } + /> + ))} + + )} + + + + + + )} + + + + + ); +}; + +const SwitchWithLabel = ({ label, value, onValueChange }) => ( + + + {label} + +); + +const styles = StyleSheet.create({ + modalContainer: { + marginHorizontal: 24, + borderRadius: 16, + //paddingVertical: 12, + paddingHorizontal: 12, + maxHeight: "60j%", + + //minHeight: 200, + }, + kbWrapper: { + flex: 1, + }, + modalTitle: { + paddingHorizontal: 4, + paddingBottom: 8, + }, + formScroll: { + marginTop: 8, + }, + rowWrap: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "space-between", + marginBottom: 12, + }, + checkboxItem: { + flexDirection: "row", + alignItems: "center", + marginRight: 12, + marginVertical: 4, + }, + checkboxLabel: { + fontSize: 14, + marginLeft: 8, + }, + typeChips: { + flexDirection: "row", + flexWrap: "wrap", + marginBottom: 12, + }, + chip: { + margin: 4, + }, + textArea: { + minHeight: 160, + marginBottom: 8, + }, + relatedRosContainer: { + marginTop: 16, + }, + relatedRosLabel: { + fontWeight: "600", + marginBottom: 8, + }, + actions: { + flexDirection: "row", + justifyContent: "flex-end", + marginTop: 16, + }, + scrollContent: { + // paddingBottom: 24, + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NewNoteModal); diff --git a/components/job-status-selector/JobStatusSelector.tsx b/components/job-status-selector/job-status-selector.jsx similarity index 77% rename from components/job-status-selector/JobStatusSelector.tsx rename to components/job-status-selector/job-status-selector.jsx index f411880..c04807a 100644 --- a/components/job-status-selector/JobStatusSelector.tsx +++ b/components/job-status-selector/job-status-selector.jsx @@ -5,34 +5,16 @@ import { useLocalSearchParams } from "expo-router"; import React, { useCallback, useState } from "react"; import { FlatList, StyleSheet, View } from "react-native"; import { - Button, - Divider, - List, - Modal, - Portal, - Text, - useTheme, + Button, + Divider, + List, + Modal, + Portal, + Text, + useTheme, } from "react-native-paper"; -/** - * JobStatusSelector component contract - * Props: - * - statuses: string[] (list of available statuses) - * - currentStatus: string (currently applied status) - * - onSelect: (status: string) => void (fires when user selects a status) - * - label?: string (optional label for trigger button) - */ -export interface JobStatusSelectorProps { - statuses: string[]; - currentStatus: string | undefined; - onSelect: (status: string) => void; - label?: string; - disabled?: boolean; -} - -const keyExtractor = (item: string) => item; - -export const JobStatusSelector: React.FC = ({ +export const JobStatusSelector = ({ statuses, currentStatus, onSelect, @@ -49,7 +31,7 @@ export const JobStatusSelector: React.FC = ({ const [updateJobStatus] = useMutation(UPDATE_JOB_STATUS); const handleSelect = useCallback( - async (status: string) => { + async (status) => { Haptics.selectionAsync().catch(() => {}); hide(); await updateJobStatus({ @@ -89,7 +71,7 @@ export const JobStatusSelector: React.FC = ({ item} renderItem={({ item }) => { const selected = item === currentStatus; return ( @@ -155,12 +137,3 @@ const styles = StyleSheet.create({ }); export default JobStatusSelector; - -/** - * Usage example: - * console.log('Status changed to', newStatus)} - * /> - */ diff --git a/components/job-tombstone/job-tombstone.jsx b/components/job-tombstone/job-tombstone.jsx index 4856bb9..e452107 100644 --- a/components/job-tombstone/job-tombstone.jsx +++ b/components/job-tombstone/job-tombstone.jsx @@ -5,17 +5,12 @@ import { useLocalSearchParams } from "expo-router"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { RefreshControl, ScrollView, StyleSheet, View } from "react-native"; -import { - ActivityIndicator, - Card, - Chip, - Text, - useTheme, -} from "react-native-paper"; +import { ActivityIndicator, Card, Chip, Text } from "react-native-paper"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import DataLabelComponent from "../data-label/data-label"; -import { JobStatusSelector } from "../job-status-selector/JobStatusSelector"; +import ErrorDisplay from "../error/error-display"; +import { JobStatusSelector } from "../job-status-selector/job-status-selector"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -31,9 +26,6 @@ function JobTombstone({ bodyshop }) { }, skip: !jobId, }); - console.log("JobTombstone render"); - - const theme = useTheme(); const { t } = useTranslation(); const onRefresh = async () => { @@ -80,6 +72,11 @@ function JobTombstone({ bodyshop }) { if (loading) { return ; } + + if (error) { + return ; + } + if (!data.jobs_by_pk) { return ( diff --git a/graphql/notes.queries.js b/graphql/notes.queries.js new file mode 100644 index 0000000..f920296 --- /dev/null +++ b/graphql/notes.queries.js @@ -0,0 +1,22 @@ +import { gql } from "graphql-tag"; + +// TODO: Confirm backend schema for notes table. Assumed fields: +// id (uuid), jobid (uuid), text (String!), critical (Boolean), private (Boolean), pinned (Boolean), type (String), created_at (timestamptz), created_by (String) +// relatedros is assumed handled via separate linking if required. + +export const INSERT_NEW_NOTE = gql` + mutation INSERT_NEW_NOTE($note: [notes_insert_input!]!) { + insert_notes(objects: $note) { + returning { + id + text + critical + private + pinned + type + created_at + created_by + } + } + } +`; diff --git a/package-lock.json b/package-lock.json index 7a5a884..0d0b046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@reduxjs/toolkit": "^2.9.1", "axios": "^1.12.2", "dinero.js": "^1.9.1", - "expo": "54.0.20", + "expo": "54.0.21", "expo-application": "~7.0.7", "expo-constants": "~18.0.10", "expo-dev-client": "~6.0.16", @@ -2305,9 +2305,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "54.0.7", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.7.tgz", - "integrity": "sha512-bXluEygLrd7cIh/erpjIIC2xDeanaebcwzF+DUMD5vAqHU3o0QXAF3jRV/LsjXZud9V5eRpyCRZ3tLQL0iv8WA==", + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.8.tgz", + "integrity": "sha512-rCkDQ8IT6sgcGNy48O2cTE4NlazCAgAIsD5qBsNPJLZSS0XbaILvAgGsFt/4nrx0GMGj6iQcOn5ifwV4NssTmw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -8184,19 +8184,19 @@ "license": "MIT" }, "node_modules/expo": { - "version": "54.0.20", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.20.tgz", - "integrity": "sha512-mWHky+H63W60P5Oo+VbtqzF2sLvdaoSSwG57H9rlq1DrgIla++QJZuwJkXXo55lYPymVmkVhwG6FjWYKKylwpw==", + "version": "54.0.21", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.21.tgz", + "integrity": "sha512-I3kzMNW/43a71pt6hT0Zebd2zAPIMMeucUDDEdfUKYrzzTRwISZfVAv0dp8GWKHHDjZsy+FjE4RQCMdyKmiDeQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.13", + "@expo/cli": "54.0.14", "@expo/config": "~12.0.10", "@expo/config-plugins": "~54.0.2", "@expo/devtools": "0.1.7", "@expo/fingerprint": "0.15.2", "@expo/metro": "~54.1.0", - "@expo/metro-config": "54.0.7", + "@expo/metro-config": "54.0.8", "@expo/vector-icons": "^15.0.3", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~54.0.6", @@ -8206,7 +8206,7 @@ "expo-font": "~14.0.9", "expo-keep-awake": "~15.0.7", "expo-modules-autolinking": "3.0.19", - "expo-modules-core": "3.0.22", + "expo-modules-core": "3.0.23", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-without-unicode": "8.0.0-3" @@ -8516,9 +8516,9 @@ } }, "node_modules/expo-modules-core": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.22.tgz", - "integrity": "sha512-FqG5oelITFTLcIfGwoJP8Qsk65be/eiEjz354NdAurnhFARHAVYOOIsUehArvm75ISdZOIZEaTSjCudmkA3kKg==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.23.tgz", + "integrity": "sha512-NYHi5LK/cdIyOjK9ZQAgfDPCOqER26cIbU3gzsce7YdnsmlNFR0qMfWOj9zAmaFBviC2kCkCOmitwk4357Td3Q==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -8638,9 +8638,9 @@ } }, "node_modules/expo-server": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.2.tgz", - "integrity": "sha512-QlQLjFuwgCiBc+Qq0IyBBHiZK1RS0NJSsKVB5iECMJrR04q7PhkaF7dON0fhvo00COy4fT9rJ5brrJDpFro/gA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.3.tgz", + "integrity": "sha512-SOwdzM/BFAL+vTFlUDJG6ljhyk6TyTl+LRK3ubGmN+Pf18ENRqKj37U8krc5vH926sAsB3IFcE8kJEYf4dG7PA==", "license": "MIT", "engines": { "node": ">=20.16.0" @@ -8786,9 +8786,9 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.13", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.13.tgz", - "integrity": "sha512-wUJVTByZzDN0q8UjXDlu6WD2BWoTJCKVVBGUBNmvViDX4FhnESwefmtXPoO54QUUKs6vY89WZryHllGArGfLLw==", + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.14.tgz", + "integrity": "sha512-M7QW/GHx1FJg+CGgChGKerYXmCGWDskJ8S6w+8m49IBZ41CMDeWRH5snQkFoGCttF8WnzhGiX+nu69AFnEuDHQ==", "license": "MIT", "dependencies": { "@0no-co/graphql.web": "^1.0.8", @@ -8801,7 +8801,7 @@ "@expo/json-file": "^10.0.7", "@expo/mcp-tunnel": "~0.0.7", "@expo/metro": "~54.1.0", - "@expo/metro-config": "~54.0.7", + "@expo/metro-config": "~54.0.8", "@expo/osascript": "^2.3.7", "@expo/package-manager": "^1.9.8", "@expo/plist": "^0.4.7", @@ -8824,7 +8824,7 @@ "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", - "expo-server": "^1.0.2", + "expo-server": "^1.0.3", "freeport-async": "^2.0.0", "getenv": "^2.0.0", "glob": "^10.4.2", diff --git a/translations/en-US/common.json b/translations/en-US/common.json index 4fba6dc..579fe1f 100644 --- a/translations/en-US/common.json +++ b/translations/en-US/common.json @@ -12,6 +12,8 @@ }, "general": { "actions": { + "add": "Add", + "cancel": "Cancel", "signout": "Sign Out" }, "labels": { @@ -26,6 +28,9 @@ } }, "jobdetail": { + "actions": { + "addnote": "Add Note" + }, "labels": { "claiminformation": "Claim Information", "dates": "Dates", @@ -286,6 +291,26 @@ "labels": { "inproduction": "In Production" } + }, + "notes": { + "fields": { + "critical": "Critical?", + "pinned": "Pinned?", + "private": "Private?", + "text": "Note Text", + "types": { + "customer": "Customer", + "general": "General", + "office": "Office", + "paint": "Paint", + "parts": "Parts", + "shop": "Shop", + "supplement": "Supplement" + } + }, + "labels": { + "newnoteplaceholder": "Enter a new note..." + } } }, "production": { diff --git a/translations/es-MX/common.json b/translations/es-MX/common.json index 8bc4793..6e2cb7f 100644 --- a/translations/es-MX/common.json +++ b/translations/es-MX/common.json @@ -12,6 +12,8 @@ }, "general": { "actions": { + "add": "", + "cancel": "", "signout": "" }, "labels": { @@ -26,6 +28,9 @@ } }, "jobdetail": { + "actions": { + "addnote": "" + }, "labels": { "claiminformation": "", "dates": "", @@ -286,6 +291,26 @@ "labels": { "inproduction": "" } + }, + "notes": { + "fields": { + "critical": "", + "pinned": "", + "private": "", + "text": "", + "types": { + "customer": "", + "general": "", + "office": "", + "paint": "", + "parts": "", + "shop": "", + "supplement": "" + } + }, + "labels": { + "newnoteplaceholder": "" + } } }, "production": { diff --git a/translations/fr-CA/common.json b/translations/fr-CA/common.json index d5b903f..a1afa92 100644 --- a/translations/fr-CA/common.json +++ b/translations/fr-CA/common.json @@ -12,6 +12,8 @@ }, "general": { "actions": { + "add": "", + "cancel": "", "signout": "" }, "labels": { @@ -26,6 +28,9 @@ } }, "jobdetail": { + "actions": { + "addnote": "" + }, "labels": { "claiminformation": "", "dates": "", @@ -286,6 +291,26 @@ "labels": { "inproduction": "" } + }, + "notes": { + "fields": { + "critical": "", + "pinned": "", + "private": "", + "text": "", + "types": { + "customer": "", + "general": "", + "office": "", + "paint": "", + "parts": "", + "shop": "", + "supplement": "" + } + }, + "labels": { + "newnoteplaceholder": "" + } } }, "production": {