import { GET_JOB_NOTES } 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_NOTES, 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 ( { 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, gap: 12, }, scrollContent: { // paddingBottom: 24, }, }); export default connect(mapStateToProps, mapDispatchToProps)(NewNoteModal);