Files
sgw-owner-app/components/diary/TripFormModal/index.tsx

583 lines
17 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ScrollView,
Alert,
ActivityIndicator,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import FishingGearList from "@/components/diary/TripFormModal/FishingGearList";
import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList";
import TripNameInput from "@/components/diary/TripFormModal/TripNameInput";
import TripDurationPicker from "@/components/diary/TripFormModal/TripDurationPicker";
import PortSelector from "@/components/diary/TripFormModal/PortSelector";
import BasicInfoInput from "@/components/diary/TripFormModal/BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
import { createTrip, updateTrip } from "@/controller/TripController";
// Internal component interfaces - extend from Model with local id for state management
export interface FishingGear extends Model.FishingGear {
id: string;
}
export interface TripCost extends Model.TripCost {
id: string;
}
interface TripFormModalProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
mode?: 'add' | 'edit' | 'view';
tripData?: Model.Trip;
}
export default function TripFormModal({
visible,
onClose,
onSuccess,
mode = 'add',
tripData,
}: TripFormModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const isEditMode = mode === 'edit';
const isViewMode = mode === 'view';
const isReadOnly = isViewMode; // View mode is read-only
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Handle animation when modal visibility changes
useEffect(() => {
if (visible) {
// Open animation: fade overlay + slide content up
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
// Form state
const [selectedShipId, setSelectedShipId] = useState<string>("");
const [tripName, setTripName] = useState("");
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [departurePortId, setDeparturePortId] = useState<number>(1);
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Pre-fill form when in edit or view mode
useEffect(() => {
if ((isEditMode || isViewMode) && tripData && visible) {
// Fill ship ID (use vms_id as thingId)
setSelectedShipId(tripData.vms_id || "");
// Fill trip name
setTripName(tripData.name || "");
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
id: `${mode}-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
}));
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `${mode}-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill dates
if (tripData.departure_time) {
setStartDate(new Date(tripData.departure_time));
}
if (tripData.arrival_time) {
setEndDate(new Date(tripData.arrival_time));
}
// Fill ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
}
}, [isEditMode, isViewMode, tripData, visible, mode]);
const handleCancel = () => {
// Close animation: fade overlay + slide content down
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get('window').height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
// Close modal after animation completes
onClose();
// Only reset form in add/edit mode, not needed for view mode
if (!isViewMode) {
// Reset form after closing
setTimeout(() => {
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
}, 100);
}
});
};
// Handle close modal after successful submit - always resets form
const handleSuccessClose = () => {
// Reset animation values for next open
fadeAnim.setValue(0);
slideAnim.setValue(Dimensions.get('window').height);
// Close modal immediately
onClose();
// Reset form after closing
setTimeout(() => {
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
}, 100);
};
// Handle auto-fill from last trip data
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
setSelectedShipId(selectedThingId);
// Fill trip name
// if (tripData.name) {
// setTripName(tripData.name);
// }
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map(
(gear, index) => ({
id: `auto-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
})
);
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `auto-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill departure and arrival ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (
tripData.fishing_ground_codes &&
Array.isArray(tripData.fishing_ground_codes)
) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
};
const handleSubmit = async () => {
// Validate thingId is required
if (!selectedShipId) {
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
return;
}
// Validate dates are required
if (!startDate || !endDate) {
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return;
}
// Validate trip name is required
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return;
}
// Parse fishing ground codes from comma-separated string to array of numbers
const fishingGroundCodesArray = fishingGroundCodes
.split(",")
.map((code) => parseInt(code.trim()))
.filter((code) => !isNaN(code));
// Format API body
const apiBody: Model.TripAPIBody = {
thing_id: selectedShipId,
name: tripName,
departure_time: startDate ? startDate.toISOString() : "",
departure_port_id: departurePortId,
arrival_time: endDate ? endDate.toISOString() : "",
arrival_port_id: arrivalPortId,
fishing_ground_codes: fishingGroundCodesArray,
fishing_gears: fishingGears.map((gear) => ({
name: gear.name,
number: gear.number,
})),
trip_cost: tripCosts.map((cost) => ({
type: cost.type,
amount: cost.amount,
unit: cost.unit,
cost_per_unit: cost.cost_per_unit,
total_cost: cost.total_cost,
})),
};
setIsSubmitting(true);
try {
let response;
if (isEditMode && tripData) {
// Edit mode: call updateTrip
response = await updateTrip(tripData.id, apiBody);
} else {
// Add mode: call createTrip
response = await createTrip(selectedShipId, apiBody);
}
// Check if response is successful (response exists)
// Call onSuccess callback first (to refresh data)
if (onSuccess) {
onSuccess();
}
// Show success alert
Alert.alert(
t("common.success"),
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
);
// Reset form and close modal
console.log("Calling handleSuccessClose");
handleSuccessClose();
} catch (error: any) {
console.error(isEditMode ? "Error updating trip:" : "Error creating trip:", error);
// Log detailed error information for debugging
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response data:", JSON.stringify(error.response.data, null, 2));
}
console.log("Request body was:", JSON.stringify(apiBody, null, 2));
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
);
} finally {
setIsSubmitting(false);
}
};
const themedStyles = {
modalContainer: {
backgroundColor: colors.card,
},
header: {
borderBottomColor: colors.separator,
},
title: {
color: colors.text,
},
footer: {
borderTopColor: colors.separator,
},
cancelButton: {
backgroundColor: colors.backgroundSecondary,
},
cancelButtonText: {
color: colors.textSecondary,
},
submitButton: {
backgroundColor: colors.primary,
},
};
return (
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleCancel}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] }
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{isViewMode
? t("diary.viewTrip")
: isEditMode
? t("diary.editTrip")
: t("diary.addTrip")}
</Text>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
contentContainerStyle={isViewMode ? { paddingBottom: 40 } : undefined}
>
{/* Auto Fill Section - only show in add mode */}
{!isEditMode && !isViewMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* Ship Selector - disabled in edit and view mode */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
disabled={isEditMode || isViewMode}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
{/* Fishing Gear List */}
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
{/* Trip Cost List */}
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
disabled={isReadOnly}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
disabled={isReadOnly}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
disabled={isReadOnly}
/>
</ScrollView>
{/* Footer - hide in view mode */}
{!isViewMode && (
<View style={[styles.footer, themedStyles.footer]}>
<TouchableOpacity
style={[styles.cancelButton, themedStyles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text
style={[styles.cancelButtonText, themedStyles.cancelButtonText]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
themedStyles.submitButton,
isSubmitting && styles.submitButtonDisabled
]}
onPress={handleSubmit}
activeOpacity={0.7}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}>
{isEditMode ? t("diary.saveChanges") : t("diary.createTrip")}
</Text>
)}
</TouchableOpacity>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
placeholder: {
width: 32,
},
content: {
padding: 20,
},
footer: {
flexDirection: "row",
gap: 12,
padding: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});