Files

588 lines
17 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;
}
// Validate: thời điểm khởi hành phải từ hiện tại trở đi
const now = new Date();
const startMinutes = Math.floor(startDate.getTime() / 60000);
const nowMinutes = Math.floor(now.getTime() / 60000);
if (startMinutes < nowMinutes) {
Alert.alert(
t("common.error"),
t("diary.validation.startDateNotInPast") ||
"Thời điểm khởi hành không được ở quá khứ"
);
return false;
}
// Validate: thời điểm kết thúc phải sau thời điểm khởi hành
if (endDate <= startDate) {
Alert.alert(
t("common.error"),
t("diary.validation.endDateAfterStart") ||
"Thời điểm kết thúc phải sau thời điểm khởi hành"
);
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 {
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,
})),
};
}, [
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
);
// Lấy message từ server response
const serverMessage = error.response?.data || "";
// Kiểm tra lỗi cụ thể: trip already exists
if (
serverMessage.includes &&
serverMessage.includes("already exists and not completed")
) {
// Đánh dấu lỗi đã được xử lý (axios sẽ không hiển thị toast cho status 400)
Alert.alert(
t("common.warning") || "Cảnh báo",
t("diary.tripAlreadyExistsError") ||
"Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới."
);
} else {
// Các lỗi khác
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",
}),
},
});