528 lines
15 KiB
TypeScript
528 lines
15 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } 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 "./FishingGearList";
|
|
import MaterialCostList from "./MaterialCostList";
|
|
import TripNameInput from "./TripNameInput";
|
|
import TripDurationPicker from "./TripDurationPicker";
|
|
import PortSelector from "./PortSelector";
|
|
import BasicInfoInput from "./BasicInfoInput";
|
|
import ShipSelector from "./ShipSelector";
|
|
import AutoFillSection from "./AutoFillSection";
|
|
import FormSection from "./FormSection";
|
|
import { createTrip, updateTrip } from "@/controller/TripController";
|
|
import {
|
|
convertFishingGears,
|
|
convertTripCosts,
|
|
convertFishingGroundCodes,
|
|
parseFishingGroundCodes,
|
|
} from "@/utils/tripDataConverters";
|
|
|
|
// 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";
|
|
tripData?: Model.Trip;
|
|
}
|
|
|
|
// Default form state
|
|
const DEFAULT_FORM_STATE = {
|
|
selectedShipId: "",
|
|
tripName: "",
|
|
fishingGears: [] as FishingGear[],
|
|
tripCosts: [] as TripCost[],
|
|
startDate: null as Date | null,
|
|
endDate: null as Date | null,
|
|
departurePortId: 1,
|
|
arrivalPortId: 1,
|
|
fishingGroundCodes: "",
|
|
};
|
|
|
|
export default function TripFormModal({
|
|
visible,
|
|
onClose,
|
|
onSuccess,
|
|
mode = "add",
|
|
tripData,
|
|
}: TripFormModalProps) {
|
|
const { t } = useI18n();
|
|
const { colors } = useThemeContext();
|
|
|
|
const isEditMode = mode === "edit";
|
|
|
|
// Animation values
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
const slideAnim = useRef(
|
|
new Animated.Value(Dimensions.get("window").height)
|
|
).current;
|
|
|
|
// Form state
|
|
const [selectedShipId, setSelectedShipId] = useState(
|
|
DEFAULT_FORM_STATE.selectedShipId
|
|
);
|
|
const [tripName, setTripName] = useState(DEFAULT_FORM_STATE.tripName);
|
|
const [fishingGears, setFishingGears] = useState<FishingGear[]>(
|
|
DEFAULT_FORM_STATE.fishingGears
|
|
);
|
|
const [tripCosts, setTripCosts] = useState<TripCost[]>(
|
|
DEFAULT_FORM_STATE.tripCosts
|
|
);
|
|
const [startDate, setStartDate] = useState<Date | null>(
|
|
DEFAULT_FORM_STATE.startDate
|
|
);
|
|
const [endDate, setEndDate] = useState<Date | null>(
|
|
DEFAULT_FORM_STATE.endDate
|
|
);
|
|
const [departurePortId, setDeparturePortId] = useState(
|
|
DEFAULT_FORM_STATE.departurePortId
|
|
);
|
|
const [arrivalPortId, setArrivalPortId] = useState(
|
|
DEFAULT_FORM_STATE.arrivalPortId
|
|
);
|
|
const [fishingGroundCodes, setFishingGroundCodes] = useState(
|
|
DEFAULT_FORM_STATE.fishingGroundCodes
|
|
);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Reset form to default state
|
|
const resetForm = useCallback(() => {
|
|
setSelectedShipId(DEFAULT_FORM_STATE.selectedShipId);
|
|
setTripName(DEFAULT_FORM_STATE.tripName);
|
|
setFishingGears(DEFAULT_FORM_STATE.fishingGears);
|
|
setTripCosts(DEFAULT_FORM_STATE.tripCosts);
|
|
setStartDate(DEFAULT_FORM_STATE.startDate);
|
|
setEndDate(DEFAULT_FORM_STATE.endDate);
|
|
setDeparturePortId(DEFAULT_FORM_STATE.departurePortId);
|
|
setArrivalPortId(DEFAULT_FORM_STATE.arrivalPortId);
|
|
setFishingGroundCodes(DEFAULT_FORM_STATE.fishingGroundCodes);
|
|
}, []);
|
|
|
|
// Fill form with trip data
|
|
const fillFormWithTripData = useCallback(
|
|
(data: Model.Trip, prefix: string) => {
|
|
setSelectedShipId(data.vms_id || "");
|
|
setTripName(data.name || "");
|
|
setFishingGears(convertFishingGears(data.fishing_gears, prefix));
|
|
setTripCosts(convertTripCosts(data.trip_cost, prefix));
|
|
if (data.departure_time) setStartDate(new Date(data.departure_time));
|
|
if (data.arrival_time) setEndDate(new Date(data.arrival_time));
|
|
if (data.departure_port_id) setDeparturePortId(data.departure_port_id);
|
|
if (data.arrival_port_id) setArrivalPortId(data.arrival_port_id);
|
|
setFishingGroundCodes(
|
|
convertFishingGroundCodes(data.fishing_ground_codes)
|
|
);
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Handle animation when modal visibility changes
|
|
useEffect(() => {
|
|
if (visible) {
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(slideAnim, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, fadeAnim, slideAnim]);
|
|
|
|
// Pre-fill form when in edit mode
|
|
useEffect(() => {
|
|
if (isEditMode && tripData && visible) {
|
|
fillFormWithTripData(tripData, "edit");
|
|
}
|
|
}, [isEditMode, tripData, visible, fillFormWithTripData]);
|
|
|
|
// Close modal with animation
|
|
const closeWithAnimation = useCallback(
|
|
(shouldResetForm: boolean = true) => {
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(slideAnim, {
|
|
toValue: Dimensions.get("window").height,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
onClose();
|
|
if (shouldResetForm) {
|
|
setTimeout(resetForm, 100);
|
|
}
|
|
});
|
|
},
|
|
[fadeAnim, slideAnim, onClose, resetForm]
|
|
);
|
|
|
|
// Handle cancel
|
|
const handleCancel = useCallback(() => {
|
|
closeWithAnimation(true);
|
|
}, [closeWithAnimation]);
|
|
|
|
// Handle close after successful submit
|
|
const handleSuccessClose = useCallback(() => {
|
|
fadeAnim.setValue(0);
|
|
slideAnim.setValue(Dimensions.get("window").height);
|
|
onClose();
|
|
setTimeout(resetForm, 100);
|
|
}, [fadeAnim, slideAnim, onClose, resetForm]);
|
|
|
|
// Handle auto-fill from last trip data
|
|
const handleAutoFill = useCallback(
|
|
(tripData: Model.Trip, selectedThingId: string) => {
|
|
setSelectedShipId(selectedThingId);
|
|
setFishingGears(convertFishingGears(tripData.fishing_gears, "auto"));
|
|
setTripCosts(convertTripCosts(tripData.trip_cost, "auto"));
|
|
if (tripData.departure_port_id)
|
|
setDeparturePortId(tripData.departure_port_id);
|
|
if (tripData.arrival_port_id) setArrivalPortId(tripData.arrival_port_id);
|
|
setFishingGroundCodes(
|
|
convertFishingGroundCodes(tripData.fishing_ground_codes)
|
|
);
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Validate form
|
|
const validateForm = useCallback((): boolean => {
|
|
if (!selectedShipId) {
|
|
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
|
|
return false;
|
|
}
|
|
if (!startDate || !endDate) {
|
|
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
|
|
return false;
|
|
}
|
|
if (!tripName.trim()) {
|
|
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
|
|
return false;
|
|
}
|
|
return true;
|
|
}, [selectedShipId, startDate, endDate, tripName, t]);
|
|
|
|
// Build API body
|
|
const buildApiBody = useCallback((): Model.TripAPIBody => {
|
|
return {
|
|
thing_id: selectedShipId,
|
|
name: tripName,
|
|
departure_time: startDate?.toISOString() || "",
|
|
departure_port_id: departurePortId,
|
|
arrival_time: endDate?.toISOString() || "",
|
|
arrival_port_id: arrivalPortId,
|
|
fishing_ground_codes: parseFishingGroundCodes(fishingGroundCodes),
|
|
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,
|
|
})),
|
|
};
|
|
}, [
|
|
selectedShipId,
|
|
tripName,
|
|
startDate,
|
|
endDate,
|
|
departurePortId,
|
|
arrivalPortId,
|
|
fishingGroundCodes,
|
|
fishingGears,
|
|
tripCosts,
|
|
]);
|
|
|
|
// Handle form submit
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!validateForm()) return;
|
|
|
|
const apiBody = buildApiBody();
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
if (isEditMode && tripData) {
|
|
await updateTrip(tripData.id, apiBody);
|
|
} else {
|
|
await createTrip(selectedShipId, apiBody);
|
|
}
|
|
|
|
onSuccess?.();
|
|
Alert.alert(
|
|
t("common.success"),
|
|
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
|
|
);
|
|
handleSuccessClose();
|
|
} catch (error: any) {
|
|
console.error(
|
|
isEditMode ? "Error updating trip:" : "Error creating trip:",
|
|
error
|
|
);
|
|
Alert.alert(
|
|
t("common.error"),
|
|
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
|
|
);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}, [
|
|
validateForm,
|
|
buildApiBody,
|
|
isEditMode,
|
|
tripData,
|
|
selectedShipId,
|
|
onSuccess,
|
|
t,
|
|
handleSuccessClose,
|
|
]);
|
|
|
|
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]}>
|
|
{isEditMode ? t("diary.editTrip") : t("diary.addTrip")}
|
|
</Text>
|
|
<View style={styles.placeholder} />
|
|
</View>
|
|
|
|
{/* Content */}
|
|
<ScrollView
|
|
style={styles.content}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Auto Fill Section - only show in add mode */}
|
|
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
|
|
|
|
{/* Section 1: Basic Information */}
|
|
<FormSection title={t("diary.formSection.basicInfo")} icon="boat-outline">
|
|
{/* Ship Selector - disabled in edit mode */}
|
|
<ShipSelector
|
|
selectedShipId={selectedShipId}
|
|
onChange={setSelectedShipId}
|
|
disabled={isEditMode}
|
|
/>
|
|
|
|
{/* Trip Name */}
|
|
<TripNameInput value={tripName} onChange={setTripName} />
|
|
</FormSection>
|
|
|
|
{/* Section 2: Schedule & Location */}
|
|
<FormSection title={t("diary.formSection.schedule")} icon="calendar-outline">
|
|
{/* Trip Duration */}
|
|
<TripDurationPicker
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onStartDateChange={setStartDate}
|
|
onEndDateChange={setEndDate}
|
|
/>
|
|
|
|
{/* Port Selector */}
|
|
<PortSelector
|
|
departurePortId={departurePortId}
|
|
arrivalPortId={arrivalPortId}
|
|
onDeparturePortChange={setDeparturePortId}
|
|
onArrivalPortChange={setArrivalPortId}
|
|
/>
|
|
|
|
{/* Fishing Ground Codes */}
|
|
<BasicInfoInput
|
|
fishingGroundCodes={fishingGroundCodes}
|
|
onChange={setFishingGroundCodes}
|
|
/>
|
|
</FormSection>
|
|
|
|
{/* Section 3: Equipment */}
|
|
<FormSection title={t("diary.formSection.equipment")} icon="construct-outline">
|
|
<FishingGearList items={fishingGears} onChange={setFishingGears} hideTitle />
|
|
</FormSection>
|
|
|
|
{/* Section 4: Costs */}
|
|
<FormSection title={t("diary.formSection.costs")} icon="wallet-outline">
|
|
<MaterialCostList items={tripCosts} onChange={setTripCosts} hideTitle />
|
|
</FormSection>
|
|
</ScrollView>
|
|
|
|
{/* Footer */}
|
|
<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",
|
|
}),
|
|
},
|
|
});
|