378 lines
11 KiB
JavaScript
378 lines
11 KiB
JavaScript
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,
|
|
};
|
|
|
|
// 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"
|
|
disabled={loading}
|
|
icon="close"
|
|
>
|
|
{t("general.actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
onPress={handleSubmit}
|
|
mode="contained"
|
|
loading={loading}
|
|
disabled={loading || !values.text.trim()}
|
|
icon="plus"
|
|
>
|
|
{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,
|
|
gap: 12,
|
|
},
|
|
scrollContent: {
|
|
// paddingBottom: 24,
|
|
},
|
|
});
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(NewNoteModal);
|