Note insert.
This commit is contained in:
@@ -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"
|
||||
|
||||
370
components/job-notes/new-note-modal.jsx
Normal file
370
components/job-notes/new-note-modal.jsx
Normal 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);
|
||||
@@ -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)}
|
||||
* />
|
||||
*/
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user