Note insert.

This commit is contained in:
Patrick Fic
2025-10-29 09:33:18 -07:00
parent 22ce0a4703
commit fde918c1ba
10 changed files with 891 additions and 89 deletions

View File

@@ -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 (
<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} />}
/>
<View style={{ flex: 1, display: "flex" }}>
<Button
icon="plus"
mode="outlined"
onPress={showNoteModal}
style={{ margin: 8 }}
>
{t("jobdetail.actions.addnote")}
</Button>
{job.notes.length === 0 ? (
<Card>
<Card.Content>
<Text>{t("jobdetail.labels.nojobnotes")}</Text>
</Card.Content>
</Card>
) : (
<FlatList
refreshControl={
<RefreshControl refreshing={loading} onRefresh={onRefresh} />
}
style={{ flex: 1 }}
data={job.notes}
renderItem={(object) => <NoteListItem item={object.item} />}
/>
)}
<NewNoteModal
jobId={jobId}
visible={noteModalVisible}
onDismiss={hideNoteModal}
onCreated={handleNoteCreated}
relatedRos={[]} // TODO: supply relatedRos list
/>
</View>
);
}
@@ -72,7 +97,7 @@ function NoteListItem({ item }) {
>
{item.private && (
<AntDesign
name="eyeo"
name="eye-invisible"
style={{ margin: 4 }}
size={24}
color="black"

View File

@@ -0,0 +1,370 @@
import { GET_JOB_BY_PK } from "@/graphql/jobs.queries";
import { INSERT_NEW_NOTE } from "@/graphql/notes.queries";
import { selectCurrentUser } from "@/redux/user/user.selectors";
import { useMutation } from "@apollo/client";
import { Formik } from "formik";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Keyboard,
KeyboardAvoidingView,
LayoutAnimation,
Platform,
ScrollView,
StyleSheet,
UIManager,
View,
} from "react-native";
import {
Button,
Chip,
Divider,
HelperText,
Modal,
Portal,
Switch,
Text,
TextInput,
useTheme,
} from "react-native-paper";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
/**
* NewNoteModal contract
* Props:
* - jobId: uuid string
* - visible: boolean - controls modal visibility
* - onDismiss: () => 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 (
<KeyboardAvoidingView
enabled={visible}
behavior={Platform.OS === "ios" ? "margin" : undefined}
keyboardVerticalOffset={Platform.select({
ios: insets.top + 60,
android: 0,
})}
style={(styles.kbWrapper, { height: !visible && 0 })} //Needed to fix flexing on parent component.
>
<Portal>
<Modal
visible={visible}
onDismiss={onDismiss}
contentContainerStyle={[
{ backgroundColor: theme.colors.surface },
styles.modalContainer,
{
marginBottom: keyboardSpace + insets.bottom,
},
]}
>
<Formik
initialValues={initialValues}
validate={(values) => {
const errors = {};
if (!values.text || !values.text.trim()) {
errors.text = t("general.validation.required");
}
return errors;
}}
onSubmit={handleSubmit}
>
{({
values,
errors,
touched,
setFieldValue,
handleSubmit,
status,
}) => (
<ScrollView
style={styles.formScroll}
contentContainerStyle={[styles.scrollContent]}
keyboardShouldPersistTaps="handled"
>
<Text variant="titleMedium" style={styles.modalTitle}>
{existingNote
? t("objects.notes.labels.editnote")
: t("jobdetail.actions.addnote")}
</Text>
<Divider style={{ marginBottom: 12 }} />
<View style={styles.rowWrap}>
<SwitchWithLabel
label={t("objects.notes.fields.critical")}
value={values.critical}
onValueChange={(v) => setFieldValue("critical", v)}
/>
<SwitchWithLabel
label={t("objects.notes.fields.private")}
value={values.private}
onValueChange={(v) => setFieldValue("private", v)}
/>
<SwitchWithLabel
label={t("objects.notes.fields.pinned")}
value={values.pinned}
onValueChange={(v) => setFieldValue("pinned", v)}
/>
</View>
<View style={styles.typeChips}>
{[
"general",
"customer",
"shop",
"office",
"parts",
"paint",
"supplement",
].map((option) => (
<Chip
key={option}
selected={values.type === option}
onPress={() => setFieldValue("type", option)}
style={styles.chip}
>
{t(`objects.notes.fields.types.${option}`)}
</Chip>
))}
</View>
<TextInput
label={t("objects.notes.fields.text")}
value={values.text}
onChangeText={(v) => setFieldValue("text", v)}
multiline
mode="outlined"
placeholder={t("objects.notes.labels.newnoteplaceholder")}
style={styles.textArea}
/>
{touched.text && errors.text && (
<HelperText type="error" visible>
{errors.text}
</HelperText>
)}
{status?.error && (
<HelperText type="error" visible>
{status.error}
</HelperText>
)}
{!existingNote && filteredRelatedRos.length > 0 && (
<View style={styles.relatedRosContainer}>
<Text style={styles.relatedRosLabel}>
{t("objects.notes.labels.addtorelatedro")}
</Text>
{filteredRelatedRos.map((j) => (
<SwitchWithLabel
key={j.id}
label={`${j.ro_number || "N/A"}${
j.clm_no ? ` | ${j.clm_no}` : ""
}${j.status ? ` | ${j.status}` : ""}`}
value={values.relatedros?.[j.id] || false}
onValueChange={(v) =>
setFieldValue(`relatedros.${j.id}`, v)
}
/>
))}
</View>
)}
<View
style={[
styles.actions,
{ paddingBottom: Math.max(insets.bottom, 12) },
]}
>
<Button onPress={onDismiss} mode="text">
{t("general.actions.cancel")}
</Button>
<Button
onPress={handleSubmit}
mode="contained"
loading={loading}
disabled={loading || !values.text.trim()}
>
{existingNote
? t("general.actions.save")
: t("general.actions.add")}
</Button>
</View>
</ScrollView>
)}
</Formik>
</Modal>
</Portal>
</KeyboardAvoidingView>
);
};
const SwitchWithLabel = ({ label, value, onValueChange }) => (
<View style={styles.checkboxItem}>
<Switch value={value} onValueChange={onValueChange} />
<Text style={styles.checkboxLabel}>{label}</Text>
</View>
);
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);

View File

@@ -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<JobStatusSelectorProps> = ({
export const JobStatusSelector = ({
statuses,
currentStatus,
onSelect,
@@ -49,7 +31,7 @@ export const JobStatusSelector: React.FC<JobStatusSelectorProps> = ({
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<JobStatusSelectorProps> = ({
<Divider />
<FlatList
data={statuses}
keyExtractor={keyExtractor}
keyExtractor={(item) => item}
renderItem={({ item }) => {
const selected = item === currentStatus;
return (
@@ -155,12 +137,3 @@ const styles = StyleSheet.create({
});
export default JobStatusSelector;
/**
* Usage example:
* <JobStatusSelector
* statuses={availableStatuses}
* currentStatus={job.status}
* onSelect={(newStatus) => console.log('Status changed to', newStatus)}
* />
*/

View File

@@ -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 <ActivityIndicator size="large" style={{ flex: 1 }} />;
}
if (error) {
return <ErrorDisplay message={JSON.stringify(error)} />;
}
if (!data.jobs_by_pk) {
return (
<Card>