From 000a4ed8565b7dd62201fc7472f7ed7f1cf5702c Mon Sep 17 00:00:00 2001 From: MinhNN Date: Tue, 23 Dec 2025 23:10:19 +0700 Subject: [PATCH] =?UTF-8?q?th=C3=AAm=20tab=20"Xem=20chi=20ti=E1=BA=BFt=20c?= =?UTF-8?q?huy=E1=BA=BFn=20=C4=91i",=20"Xem=20chi=20ti=E1=BA=BFt=20th?= =?UTF-8?q?=C3=A0nh=20vi=C3=AAn=20chuy=E1=BA=BFn=20=C4=91i",=20t=C3=A1i=20?= =?UTF-8?q?s=E1=BB=AD=20d=E1=BB=A5ng=20l=E1=BA=A1i=20components=20modal=20?= =?UTF-8?q?tripForm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 16 +- app/(tabs)/diary.tsx | 76 ++- app/_layout.tsx | 111 +++- app/trip-crew.tsx | 283 ++++++++++ app/trip-detail.tsx | 312 ++++++++++ .../diary/TripCrewModal/AddEditCrewModal.tsx | 444 +++++++++++++++ components/diary/TripCrewModal/CrewCard.tsx | 285 ++++++++++ components/diary/TripCrewModal/CrewList.tsx | 69 +++ components/diary/TripCrewModal/index.tsx | 377 +++++++++++++ .../TripDetailSections/AlertsSection.tsx | 156 +++++ .../TripDetailSections/BasicInfoSection.tsx | 135 +++++ .../TripDetailSections/FishingLogsSection.tsx | 356 ++++++++++++ .../diary/TripDetailSections/SectionCard.tsx | 127 +++++ components/diary/TripDetailSections/index.ts | 4 + .../diary/TripFormModal/MaterialCostList.tsx | 2 +- .../diary/TripFormModal/TripFormBody.tsx | 120 ++++ components/diary/TripFormModal/index.tsx | 531 +++++++----------- constants/index.ts | 3 +- controller/TripCrewController.ts | 12 + locales/en.json | 42 +- locales/vi.json | 85 ++- utils/tripDataConverters.ts | 54 ++ 22 files changed, 3221 insertions(+), 379 deletions(-) create mode 100644 app/trip-crew.tsx create mode 100644 app/trip-detail.tsx create mode 100644 components/diary/TripCrewModal/AddEditCrewModal.tsx create mode 100644 components/diary/TripCrewModal/CrewCard.tsx create mode 100644 components/diary/TripCrewModal/CrewList.tsx create mode 100644 components/diary/TripCrewModal/index.tsx create mode 100644 components/diary/TripDetailSections/AlertsSection.tsx create mode 100644 components/diary/TripDetailSections/BasicInfoSection.tsx create mode 100644 components/diary/TripDetailSections/FishingLogsSection.tsx create mode 100644 components/diary/TripDetailSections/SectionCard.tsx create mode 100644 components/diary/TripDetailSections/index.ts create mode 100644 components/diary/TripFormModal/TripFormBody.tsx create mode 100644 controller/TripCrewController.ts create mode 100644 utils/tripDataConverters.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9444e80..7f83409 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,15 +2,14 @@ import { Tabs, useSegments } from "expo-router"; import { HapticTab } from "@/components/haptic-tab"; import { IconSymbol } from "@/components/ui/icon-symbol"; -import { Colors } from "@/constants/theme"; import { queryProfile } from "@/controller/AuthController"; import { useI18n } from "@/hooks/use-i18n"; -import { useColorScheme } from "@/hooks/use-theme-context"; +import { useThemeContext } from "@/hooks/use-theme-context"; import { addUserStorage } from "@/utils/storage"; import { useEffect, useRef } from "react"; export default function TabLayout() { - const colorScheme = useColorScheme(); + const { colors, colorScheme } = useThemeContext(); const segments = useSegments() as string[]; const prev = useRef(null); const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null; @@ -51,9 +50,18 @@ export default function TabLayout() { return ( (null); - const [viewingTrip, setViewingTrip] = useState(null); const [filters, setFilters] = useState({ status: null, startDate: null, @@ -40,6 +41,10 @@ export default function diary() { const [hasMore, setHasMore] = useState(true); const isInitialLoad = useRef(true); const flatListRef = useRef(null); + + // Refs to prevent duplicate API calls from React Compiler/Strict Mode + const hasInitializedThings = useRef(false); + const hasInitializedTrips = useRef(false); // Body call API things (đang fix cứng) const payloadThings: Model.SearchThingBody = { @@ -55,6 +60,8 @@ export default function diary() { // Gọi API things const { getThings } = useThings(); useEffect(() => { + if (hasInitializedThings.current) return; + hasInitializedThings.current = true; getThings(payloadThings); }, []); @@ -79,10 +86,10 @@ export default function diary() { const { tripsList, getTripsList, loading } = useTripsList(); - // console.log("Payload trips:", payloadTrips); - // Gọi API trips lần đầu useEffect(() => { + if (hasInitializedTrips.current) return; + hasInitializedTrips.current = true; isInitialLoad.current = true; setAllTrips([]); setHasMore(true); @@ -157,7 +164,11 @@ export default function diary() { // Hàm load more data khi scroll đến cuối const handleLoadMore = useCallback(() => { - if (isLoadingMore || !hasMore) { + // Không load more nếu: + // - Đang loading (ban đầu hoặc loadMore) + // - Không còn data để load + // - Chưa có data nào (tránh FlatList tự trigger khi list rỗng) + if (isLoadingMore || loading || !hasMore || allTrips.length === 0) { return; } @@ -169,7 +180,7 @@ export default function diary() { }; setPayloadTrips(updatedPayload); getTripsList(updatedPayload); - }, [isLoadingMore, hasMore, payloadTrips]); + }, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]); // const handleTripPress = (tripId: string) => { // // TODO: Navigate to trip detail @@ -177,11 +188,16 @@ export default function diary() { // }; const handleViewTrip = (tripId: string) => { - // Find the trip from allTrips and open modal in view mode + // Navigate to trip detail page instead of opening modal const tripToView = allTrips.find((trip) => trip.id === tripId); if (tripToView) { - setViewingTrip(tripToView); - setShowAddTripModal(true); + router.push({ + pathname: "/trip-detail", + params: { + tripId: tripToView.id, + tripData: JSON.stringify(tripToView) + }, + }); } }; @@ -195,8 +211,13 @@ export default function diary() { }; const handleViewTeam = (tripId: string) => { - console.log("View team:", tripId); - // TODO: Navigate to team management + const trip = allTrips.find((t) => t.id === tripId); + if (trip) { + router.push({ + pathname: "/trip-crew", + params: { tripId: trip.id, tripName: trip.name || "" }, + }); + } }; const handleSendTrip = (tripId: string) => { @@ -260,7 +281,13 @@ export default function diary() { onDelete={() => handleDeleteTrip(item.id)} /> ), - [handleViewTrip, handleEditTrip, handleViewTeam, handleSendTrip, handleDeleteTrip] + [ + handleViewTrip, + handleEditTrip, + handleViewTeam, + handleSendTrip, + handleDeleteTrip, + ] ); // Key extractor cho FlatList @@ -301,10 +328,15 @@ export default function diary() { }; return ( - + {/* Header */} - {t("diary.title")} + + {t("diary.title")} + {/* Filter & Add Button Row */} @@ -358,17 +390,16 @@ export default function diary() { onApply={handleApplyFilters} /> - {/* Add/Edit/View Trip Modal */} + {/* Add/Edit Trip Modal */} { setShowAddTripModal(false); setEditingTrip(null); - setViewingTrip(null); }} onSuccess={handleTripAddSuccess} - mode={viewingTrip ? 'view' : editingTrip ? 'edit' : 'add'} - tripData={viewingTrip || editingTrip || undefined} + mode={editingTrip ? "edit" : "add"} + tripData={editingTrip || undefined} /> ); @@ -394,17 +425,10 @@ const styles = StyleSheet.create({ }), }, actionRow: { - flexDirection: "row", - justifyContent: "flex-start", - alignItems: "center", - gap: 12, - marginBottom: 12, - }, - headerRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", - marginTop: 20, + gap: 12, marginBottom: 12, }, countText: { @@ -421,7 +445,7 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", paddingHorizontal: 16, - paddingVertical: 8, + height: 40, borderRadius: 8, gap: 6, }, diff --git a/app/_layout.tsx b/app/_layout.tsx index d9de09d..5dd0101 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,14 +2,14 @@ import { DarkTheme, DefaultTheme, ThemeProvider, + Theme, } from "@react-navigation/native"; import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { View, StyleSheet } from "react-native"; import "react-native-reanimated"; -// import Toast from "react-native-toast-message"; -// import { toastConfig } from "@/config"; import { toastConfig } from "@/config"; import { setRouterInstance } from "@/config/auth"; import "@/global.css"; @@ -18,50 +18,101 @@ import { ThemeProvider as AppThemeProvider, useThemeContext, } from "@/hooks/use-theme-context"; +import { Colors } from "@/constants/theme"; import Toast from "react-native-toast-message"; import "../global.css"; function AppContent() { const router = useRouter(); - const { colorScheme } = useThemeContext(); - console.log("Color Scheme: ", colorScheme); + const { colorScheme, colors } = useThemeContext(); useEffect(() => { setRouterInstance(router); }, [router]); + // Create custom navigation theme that uses our app colors + // This ensures the navigation container background matches our theme + const navigationTheme: Theme = useMemo(() => { + const baseTheme = colorScheme === "dark" ? DarkTheme : DefaultTheme; + return { + ...baseTheme, + colors: { + ...baseTheme.colors, + background: colors.background, + card: colors.card, + text: colors.text, + border: colors.border, + primary: colors.primary, + }, + }; + }, [colorScheme, colors]); + return ( - - - + + + initialRouteName="auth/login" + > + - + - - - - - + + + + + + + + + + ); } +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + export default function RootLayout() { return ( diff --git a/app/trip-crew.tsx b/app/trip-crew.tsx new file mode 100644 index 0000000..a43bfbb --- /dev/null +++ b/app/trip-crew.tsx @@ -0,0 +1,283 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + Platform, + TouchableOpacity, + ActivityIndicator, + Alert, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { queryTripCrew } from "@/controller/TripCrewController"; +import CrewList from "@/components/diary/TripCrewModal/CrewList"; +import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal"; + +export default function TripCrewPage() { + const { t } = useI18n(); + const { colors } = useThemeContext(); + const router = useRouter(); + const { tripId, tripName } = useLocalSearchParams<{ tripId: string; tripName?: string }>(); + + // State + const [loading, setLoading] = useState(false); + const [crews, setCrews] = useState([]); + const [error, setError] = useState(null); + + // Add/Edit modal state + const [showAddEditModal, setShowAddEditModal] = useState(false); + const [editingCrew, setEditingCrew] = useState(null); + + // Fetch crew data + const fetchCrewData = useCallback(async () => { + if (!tripId) return; + + setLoading(true); + setError(null); + try { + const response = await queryTripCrew(tripId); + const data = response.data as any; + + if (data?.trip_crews && Array.isArray(data.trip_crews)) { + setCrews(data.trip_crews); + } else if (Array.isArray(data)) { + setCrews(data); + } else { + setCrews([]); + } + } catch (err) { + console.error("Error fetching crew:", err); + setError(t("diary.crew.fetchError")); + setCrews([]); + } finally { + setLoading(false); + } + }, [tripId, t]); + + useEffect(() => { + fetchCrewData(); + }, [fetchCrewData]); + + // Add crew handler + const handleAddCrew = () => { + setEditingCrew(null); + setShowAddEditModal(true); + }; + + // Edit crew handler + const handleEditCrew = (crew: Model.TripCrews) => { + setEditingCrew(crew); + setShowAddEditModal(true); + }; + + // Delete crew handler + const handleDeleteCrew = (crew: Model.TripCrews) => { + Alert.alert( + t("diary.crew.deleteConfirmTitle"), + t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + // TODO: Call delete API when available + setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID)); + Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); + }, + }, + ] + ); + }; + + // Save crew handler + const handleSaveCrew = async (formData: any) => { + // TODO: Call API to add/edit crew when available + await fetchCrewData(); + }; + + const themedStyles = { + container: { backgroundColor: colors.background }, + header: { backgroundColor: colors.card, borderBottomColor: colors.separator }, + title: { color: colors.text }, + subtitle: { color: colors.textSecondary }, + }; + + return ( + + {/* Header */} + + router.back()} style={styles.backButton}> + + + + + {t("diary.crew.title")} + + {tripName && ( + + {tripName} + + )} + + + + + + + {/* Content */} + + {loading ? ( + + + + {t("diary.crew.loading")} + + + ) : error ? ( + + + + {error} + + + {t("common.retry")} + + + ) : ( + + )} + + + {/* Footer - Crew count */} + + + + + {t("diary.crew.totalMembers", { count: crews.length })} + + + + + {/* Add/Edit Crew Modal */} + { + setShowAddEditModal(false); + setEditingCrew(null); + }} + onSave={handleSaveCrew} + mode={editingCrew ? "edit" : "add"} + initialData={ + editingCrew + ? { + personalId: editingCrew.PersonalID, + name: editingCrew.Person?.name || "", + phone: editingCrew.Person?.phone || "", + email: editingCrew.Person?.email || "", + address: editingCrew.Person?.address || "", + role: editingCrew.role || "crew", + note: editingCrew.note || "", + } + : undefined + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + }, + backButton: { + padding: 4, + }, + addButton: { + padding: 4, + }, + headerTitles: { + flex: 1, + alignItems: "center", + }, + title: { + fontSize: 18, + fontWeight: "700", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + subtitle: { + fontSize: 13, + marginTop: 2, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + content: { + flex: 1, + padding: 16, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + errorContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + paddingHorizontal: 20, + }, + errorText: { + fontSize: 14, + textAlign: "center", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + retryButton: { + marginTop: 8, + paddingHorizontal: 24, + paddingVertical: 10, + borderRadius: 8, + }, + retryButtonText: { + color: "#FFFFFF", + fontSize: 14, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + footer: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 30, + }, + countContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + countText: { + fontSize: 14, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, +}); diff --git a/app/trip-detail.tsx b/app/trip-detail.tsx new file mode 100644 index 0000000..a4c3b81 --- /dev/null +++ b/app/trip-detail.tsx @@ -0,0 +1,312 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { + View, + Text, + StyleSheet, + Platform, + TouchableOpacity, + ScrollView, + ActivityIndicator, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { TRIP_STATUS_CONFIG } from "@/components/diary/types"; +import { + convertFishingGears, + convertTripCosts, +} from "@/utils/tripDataConverters"; + +// Reuse existing components +import CrewList from "@/components/diary/TripCrewModal/CrewList"; +import FishingGearList from "@/components/diary/TripFormModal/FishingGearList"; +import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList"; + +// Section components +import { + SectionCard, + AlertsSection, + FishingLogsSection, + BasicInfoSection, +} from "@/components/diary/TripDetailSections"; + +export default function TripDetailPage() { + const { t } = useI18n(); + const { colors } = useThemeContext(); + const router = useRouter(); + const { tripId, tripData: tripDataParam } = useLocalSearchParams<{ + tripId: string; + tripData?: string; + }>(); + + const [loading, setLoading] = useState(true); + const [trip, setTrip] = useState(null); + const [alerts] = useState([]); // TODO: Fetch from API + + // Parse trip data from params or fetch from API + useEffect(() => { + if (tripDataParam) { + try { + const parsedTrip = JSON.parse(tripDataParam) as Model.Trip; + setTrip(parsedTrip); + } catch (e) { + console.error("Error parsing trip data:", e); + } + } + // TODO: Fetch trip detail from API using tripId if not passed via params + setLoading(false); + }, [tripDataParam, tripId]); + + // Convert trip data to component format using memoization + const fishingGears = useMemo( + () => convertFishingGears(trip?.fishing_gears, "view"), + [trip?.fishing_gears] + ); + + const tripCosts = useMemo( + () => convertTripCosts(trip?.trip_cost, "view"), + [trip?.trip_cost] + ); + + const statusConfig = useMemo(() => { + const status = trip?.trip_status ?? 0; + return TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] || TRIP_STATUS_CONFIG[0]; + }, [trip?.trip_status]); + + // Empty section component + const EmptySection = ({ icon, message }: { icon: string; message: string }) => ( + + + + {message} + + + ); + + // Render loading state + if (loading) { + return ( + + + + + {t("common.loading")} + + + + ); + } + + // Render error state + if (!trip) { + return ( + +
router.back()} colors={colors} /> + + + + {t("diary.tripDetail.notFound")} + + + + ); + } + + return ( + + {/* Header with status badge */} + + router.back()} style={styles.backButton}> + + + + + {trip.name || t("diary.tripDetail.title")} + + + + + {statusConfig.label} + + + + + + + {/* Content */} + + {/* Basic Info */} + + + {/* Alerts */} + + + {/* Trip Costs */} + + {tripCosts.length > 0 ? ( + + {}} disabled /> + + ) : ( + + )} + + + {/* Fishing Gears */} + + {fishingGears.length > 0 ? ( + + {}} disabled /> + + ) : ( + + )} + + + {/* Crew List */} + + {trip.crews && trip.crews.length > 0 ? ( + + ) : ( + + )} + + + {/* Fishing Logs */} + + + + ); +} + +// Header component for reuse +function Header({ + title, + onBack, + colors, +}: { + title: string; + onBack: () => void; + colors: any; +}) { + return ( + + + + + + {title} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + }, + backButton: { + padding: 4, + }, + headerTitles: { + flex: 1, + alignItems: "center", + gap: 6, + }, + title: { + fontSize: 18, + fontWeight: "700", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + statusBadge: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 11, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + placeholder: { + width: 32, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: 16, + paddingBottom: 40, + }, + errorContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + paddingHorizontal: 20, + }, + errorText: { + fontSize: 14, + textAlign: "center", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + sectionInnerContent: { + marginTop: -8, + }, + emptySection: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 24, + gap: 8, + }, + emptyText: { + fontSize: 14, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, +}); diff --git a/components/diary/TripCrewModal/AddEditCrewModal.tsx b/components/diary/TripCrewModal/AddEditCrewModal.tsx new file mode 100644 index 0000000..3f1e08c --- /dev/null +++ b/components/diary/TripCrewModal/AddEditCrewModal.tsx @@ -0,0 +1,444 @@ +import React, { useState, useEffect } from "react"; +import { + View, + Text, + Modal, + TouchableOpacity, + StyleSheet, + Platform, + TextInput, + ScrollView, + ActivityIndicator, + Animated, + Dimensions, + Alert, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; + +interface CrewFormData { + personalId: string; + name: string; + phone: string; + email: string; + address: string; + role: string; + note: string; +} + +interface AddEditCrewModalProps { + visible: boolean; + onClose: () => void; + onSave: (data: CrewFormData) => Promise; + mode: "add" | "edit"; + initialData?: Partial; +} + +const ROLES = ["captain", "crew", "engineer", "cook"]; + +export default function AddEditCrewModal({ + visible, + onClose, + onSave, + mode, + initialData, +}: AddEditCrewModalProps) { + const { t } = useI18n(); + const { colors } = useThemeContext(); + + // Animation values + const fadeAnim = useState(new Animated.Value(0))[0]; + const slideAnim = useState(new Animated.Value(Dimensions.get("window").height))[0]; + + // Form state + const [formData, setFormData] = useState({ + personalId: "", + name: "", + phone: "", + email: "", + address: "", + role: "crew", + note: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Pre-fill form when editing + useEffect(() => { + if (visible && initialData) { + setFormData({ + personalId: initialData.personalId || "", + name: initialData.name || "", + phone: initialData.phone || "", + email: initialData.email || "", + address: initialData.address || "", + role: initialData.role || "crew", + note: initialData.note || "", + }); + } else if (visible && mode === "add") { + // Reset form for add mode + setFormData({ + personalId: "", + name: "", + phone: "", + email: "", + address: "", + role: "crew", + note: "", + }); + } + }, [visible, initialData, mode]); + + // Handle animation + 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]); + + const handleClose = () => { + 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(); + }); + }; + + const handleSave = async () => { + // Validate required fields + if (!formData.personalId.trim()) { + Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired")); + return; + } + if (!formData.name.trim()) { + Alert.alert(t("common.error"), t("diary.crew.form.nameRequired")); + return; + } + + setIsSubmitting(true); + try { + await onSave(formData); + handleClose(); + } catch (error) { + Alert.alert(t("common.error"), t("diary.crew.form.saveError")); + } finally { + setIsSubmitting(false); + } + }; + + const updateField = (field: keyof CrewFormData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const themedStyles = { + modalContainer: { backgroundColor: colors.card }, + header: { borderBottomColor: colors.separator }, + title: { color: colors.text }, + label: { color: colors.text }, + input: { + backgroundColor: colors.backgroundSecondary, + color: colors.text, + borderColor: colors.separator, + }, + placeholder: { color: colors.textSecondary }, + roleButton: { + backgroundColor: colors.backgroundSecondary, + borderColor: colors.separator, + }, + roleButtonActive: { + backgroundColor: colors.primary + "20", + borderColor: colors.primary, + }, + roleText: { color: colors.textSecondary }, + roleTextActive: { color: colors.primary }, + }; + + return ( + + + + {/* Header */} + + + + + + {mode === "add" ? t("diary.crew.form.addTitle") : t("diary.crew.form.editTitle")} + + + + + {/* Content */} + + {/* Personal ID */} + + + {t("diary.crew.personalId")} * + + updateField("personalId", v)} + placeholder={t("diary.crew.form.personalIdPlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + editable={mode === "add"} + /> + + + {/* Name */} + + + {t("diary.crew.form.name")} * + + updateField("name", v)} + placeholder={t("diary.crew.form.namePlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + /> + + + {/* Phone */} + + + {t("diary.crew.phone")} + + updateField("phone", v)} + placeholder={t("diary.crew.form.phonePlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + keyboardType="phone-pad" + /> + + + {/* Role */} + + + {t("diary.crew.form.role")} + + + {ROLES.map((role) => ( + updateField("role", role)} + > + + {t(`diary.crew.roles.${role}`)} + + + ))} + + + + {/* Address */} + + + {t("diary.crew.form.address")} + + updateField("address", v)} + placeholder={t("diary.crew.form.addressPlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + /> + + + {/* Note */} + + + {t("diary.crew.note")} + + updateField("note", v)} + placeholder={t("diary.crew.form.notePlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + multiline + numberOfLines={3} + /> + + + + {/* Footer */} + + + + {t("common.cancel")} + + + + {isSubmitting ? ( + + ) : ( + {t("common.save")} + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + modalContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + maxHeight: "90%", + }, + 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" }), + }, + headerPlaceholder: { + width: 32, + }, + content: { + padding: 20, + }, + formGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: "600", + marginBottom: 8, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + input: { + height: 44, + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 14, + fontSize: 15, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + textArea: { + height: 80, + paddingTop: 12, + textAlignVertical: "top", + }, + roleContainer: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + roleButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + }, + roleButtonText: { + fontSize: 13, + fontWeight: "500", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + 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" }), + }, + saveButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + alignItems: "center", + }, + saveButtonText: { + fontSize: 16, + fontWeight: "600", + color: "#FFFFFF", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + buttonDisabled: { + opacity: 0.7, + }, +}); diff --git a/components/diary/TripCrewModal/CrewCard.tsx b/components/diary/TripCrewModal/CrewCard.tsx new file mode 100644 index 0000000..18b7730 --- /dev/null +++ b/components/diary/TripCrewModal/CrewCard.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useState } from "react"; +import { + View, + Text, + StyleSheet, + Platform, + Image, + ActivityIndicator, + TouchableOpacity, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { useI18n } from "@/hooks/use-i18n"; +import dayjs from "dayjs"; +import { queryCrewImage } from "@/controller/TripCrewController"; +import { Buffer } from "buffer"; + +interface CrewCardProps { + crew: Model.TripCrews; + onEdit?: (crew: Model.TripCrews) => void; + onDelete?: (crew: Model.TripCrews) => void; +} + +export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { + const { colors } = useThemeContext(); + const { t } = useI18n(); + + const person = crew.Person; + const joinedDate = crew.joined_at ? dayjs(crew.joined_at).format("DD/MM/YYYY") : "-"; + const leftDate = crew.left_at ? dayjs(crew.left_at).format("DD/MM/YYYY") : null; + + // State for image + const [imageUri, setImageUri] = useState(null); + const [imageLoading, setImageLoading] = useState(true); + const [imageError, setImageError] = useState(false); + + // Fetch crew image + useEffect(() => { + const fetchImage = async () => { + if (!person?.personal_id) { + setImageLoading(false); + setImageError(true); + return; + } + + try { + const response = await queryCrewImage(person.personal_id); + if (response.data) { + // Convert arraybuffer to base64 + const base64 = Buffer.from(response.data as ArrayBuffer).toString("base64"); + setImageUri(`data:image/jpeg;base64,${base64}`); + } else { + setImageError(true); + } + } catch (err) { + setImageError(true); + } finally { + setImageLoading(false); + } + }; + + fetchImage(); + }, [person?.personal_id]); + + const themedStyles = { + card: { + backgroundColor: colors.card, + borderColor: colors.separator, + }, + name: { + color: colors.text, + }, + role: { + color: colors.primary, + backgroundColor: colors.primary + "20", + }, + label: { + color: colors.textSecondary, + }, + value: { + color: colors.text, + }, + iconColor: colors.textSecondary, + imagePlaceholder: { + backgroundColor: colors.backgroundSecondary, + }, + }; + + return ( + + {/* Left Image Section (1/3 width) */} + + {imageLoading ? ( + + ) : imageUri && !imageError ? ( + + ) : ( + + + + )} + + + {/* Right Content Section (2/3 width) */} + + {/* Name & Role */} + + + {person?.name || "-"} + + + + {crew.role || t("diary.crew.member")} + + + + + {/* Info Grid */} + + {/* Phone */} + + + + {person?.phone || "-"} + + + + {/* Personal ID */} + + + + {person?.personal_id || "-"} + + + + {/* Joined Date */} + + + {joinedDate} + + + {/* Left Date (only show if exists) */} + {leftDate && ( + + + {leftDate} + + )} + + + {/* Action Buttons */} + {(onEdit || onDelete) && ( + + {onEdit && ( + onEdit(crew)} + > + + + {t("common.edit")} + + + )} + {onDelete && ( + onDelete(crew)} + > + + + {t("common.delete")} + + + )} + + )} + + + ); +} + +const styles = StyleSheet.create({ + card: { + flexDirection: "row", + borderRadius: 12, + borderWidth: 1, + marginBottom: 12, + overflow: "hidden", + maxHeight: 150, + }, + imageSection: { + width: 130, + alignSelf: "stretch", + justifyContent: "center", + alignItems: "center", + }, + crewImage: { + width: "100%", + height: "100%", + }, + imagePlaceholder: { + flex: 1, + justifyContent: "center", + alignItems: "center", + width: "100%", + }, + contentSection: { + flex: 1, + padding: 10, + justifyContent: "center", + }, + header: { + marginBottom: 8, + }, + name: { + fontSize: 17, + fontWeight: "700", + marginBottom: 4, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + roleBadge: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + roleText: { + fontSize: 12, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + infoGrid: { + gap: 4, + }, + infoRow: { + flexDirection: "row", + alignItems: "center", + gap: 5, + }, + value: { + fontSize: 14, + fontWeight: "500", + flex: 1, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + actionRow: { + flexDirection: "row", + gap: 8, + marginTop: 8, + }, + actionButton: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 6, + }, + actionText: { + fontSize: 12, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, +}); diff --git a/components/diary/TripCrewModal/CrewList.tsx b/components/diary/TripCrewModal/CrewList.tsx new file mode 100644 index 0000000..3d9c0d3 --- /dev/null +++ b/components/diary/TripCrewModal/CrewList.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { + View, + Text, + StyleSheet, + FlatList, + Platform, +} from "react-native"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { useI18n } from "@/hooks/use-i18n"; +import CrewCard from "./CrewCard"; + +interface CrewListProps { + crews: Model.TripCrews[]; + onEdit?: (crew: Model.TripCrews) => void; + onDelete?: (crew: Model.TripCrews) => void; +} + +export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) { + const { colors } = useThemeContext(); + const { t } = useI18n(); + + const renderItem = ({ item }: { item: Model.TripCrews }) => ( + + ); + + const keyExtractor = (item: Model.TripCrews, index: number) => + `${item.PersonalID}-${index}`; + + const renderEmpty = () => ( + + + {t("diary.crew.noCrewMembers")} + + + ); + + return ( + + ); +} + +const styles = StyleSheet.create({ + listContent: { + paddingBottom: 20, + flexGrow: 1, + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingVertical: 60, + }, + emptyText: { + fontSize: 16, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, +}); diff --git a/components/diary/TripCrewModal/index.tsx b/components/diary/TripCrewModal/index.tsx new file mode 100644 index 0000000..3d868ba --- /dev/null +++ b/components/diary/TripCrewModal/index.tsx @@ -0,0 +1,377 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + View, + Text, + Modal, + TouchableOpacity, + StyleSheet, + Platform, + ActivityIndicator, + Animated, + Dimensions, + Alert, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { queryTripCrew } from "@/controller/TripCrewController"; +import CrewList from "./CrewList"; +import AddEditCrewModal from "./AddEditCrewModal"; + +interface TripCrewModalProps { + visible: boolean; + onClose: () => void; + tripId: string | null; + tripName?: string; +} + +export default function TripCrewModal({ + visible, + onClose, + tripId, + tripName, +}: TripCrewModalProps) { + const { t } = useI18n(); + const { colors } = useThemeContext(); + + // Animation values + const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current; + + // State + const [loading, setLoading] = useState(false); + const [crews, setCrews] = useState([]); + const [error, setError] = useState(null); + + // Add/Edit modal state + const [showAddEditModal, setShowAddEditModal] = useState(false); + const [editingCrew, setEditingCrew] = useState(null); + + // Fetch crew data when modal opens + useEffect(() => { + if (visible && tripId) { + fetchCrewData(); + } + }, [visible, tripId]); + + // Handle animation when modal visibility changes + useEffect(() => { + if (visible) { + setError(null); + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } + }, [visible, fadeAnim, slideAnim]); + + const fetchCrewData = async () => { + if (!tripId) return; + + setLoading(true); + setError(null); + try { + const response = await queryTripCrew(tripId); + const data = response.data as any; + + if (data?.trip_crews && Array.isArray(data.trip_crews)) { + setCrews(data.trip_crews); + } else if (Array.isArray(data)) { + setCrews(data); + } else { + setCrews([]); + } + } catch (err) { + console.error("Error fetching crew:", err); + setError(t("diary.crew.fetchError")); + setCrews([]); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + 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(); + setTimeout(() => { + setCrews([]); + setError(null); + }, 100); + }); + }; + + // Add crew handler + const handleAddCrew = () => { + setEditingCrew(null); + setShowAddEditModal(true); + }; + + // Edit crew handler + const handleEditCrew = (crew: Model.TripCrews) => { + setEditingCrew(crew); + setShowAddEditModal(true); + }; + + // Delete crew handler + const handleDeleteCrew = (crew: Model.TripCrews) => { + Alert.alert( + t("diary.crew.deleteConfirmTitle"), + t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + // TODO: Call delete API when available + // For now, just remove from local state + setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID)); + Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); + }, + }, + ] + ); + }; + + // Save crew handler (add or edit) + const handleSaveCrew = async (formData: any) => { + // TODO: Call API to add/edit crew when available + // For now, refresh the list + await fetchCrewData(); + }; + + const themedStyles = { + modalContainer: { backgroundColor: colors.card }, + header: { borderBottomColor: colors.separator }, + title: { color: colors.text }, + subtitle: { color: colors.textSecondary }, + content: { backgroundColor: colors.background }, + }; + + return ( + <> + + + + {/* Header */} + + + + + + + {t("diary.crew.title")} + + {tripName && ( + + {tripName} + + )} + + {/* Add Button */} + + + + + + {/* Content */} + + {loading ? ( + + + + {t("diary.crew.loading")} + + + ) : error ? ( + + + + {error} + + + {t("common.retry")} + + + ) : ( + + )} + + + {/* Footer - Crew count */} + + + + + {t("diary.crew.totalMembers", { count: crews.length })} + + + + + + + + {/* Add/Edit Crew Modal */} + { + setShowAddEditModal(false); + setEditingCrew(null); + }} + onSave={handleSaveCrew} + mode={editingCrew ? "edit" : "add"} + initialData={ + editingCrew + ? { + personalId: editingCrew.PersonalID, + name: editingCrew.Person?.name || "", + phone: editingCrew.Person?.phone || "", + email: editingCrew.Person?.email || "", + address: editingCrew.Person?.address || "", + role: editingCrew.role || "crew", + note: editingCrew.note || "", + } + : undefined + } + /> + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "flex-end", + }, + modalContainer: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + maxHeight: "92%", + minHeight: "80%", + 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, + }, + addButton: { + padding: 4, + }, + headerTitles: { + flex: 1, + alignItems: "center", + }, + title: { + fontSize: 18, + fontWeight: "700", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + subtitle: { + fontSize: 13, + marginTop: 2, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + content: { + flex: 1, + padding: 16, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + }, + loadingText: { + fontSize: 14, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + errorContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 12, + paddingHorizontal: 20, + }, + errorText: { + fontSize: 14, + textAlign: "center", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + retryButton: { + marginTop: 8, + paddingHorizontal: 24, + paddingVertical: 10, + borderRadius: 8, + }, + retryButtonText: { + color: "#FFFFFF", + fontSize: 14, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + footer: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 16, + }, + countContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + countText: { + fontSize: 14, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, +}); diff --git a/components/diary/TripDetailSections/AlertsSection.tsx b/components/diary/TripDetailSections/AlertsSection.tsx new file mode 100644 index 0000000..8e0030d --- /dev/null +++ b/components/diary/TripDetailSections/AlertsSection.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { View, Text, StyleSheet, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { useI18n } from "@/hooks/use-i18n"; +import SectionCard from "./SectionCard"; + +interface AlertsSectionProps { + alerts?: Model.Alarm[]; +} + +export default function AlertsSection({ alerts = [] }: AlertsSectionProps) { + const { t } = useI18n(); + const { colors } = useThemeContext(); + + const getAlertLevelColor = (level?: number) => { + switch (level) { + case 0: + return { bg: "#FEF3C7", text: "#92400E" }; // Warning - Yellow + case 1: + return { bg: "#FEE2E2", text: "#991B1B" }; // Error - Red + case 2: + return { bg: "#DBEAFE", text: "#1E40AF" }; // Info - Blue + default: + return { bg: "#F3F4F6", text: "#4B5563" }; // Default - Gray + } + }; + + const formatTime = (timestamp?: number) => { + if (!timestamp) return "--"; + const date = new Date(timestamp * 1000); + return date.toLocaleString("vi-VN"); + }; + + return ( + 0} + > + {alerts.length === 0 ? ( + + + + {t("diary.tripDetail.noAlerts")} + + + ) : ( + + {alerts.map((alert, index) => { + const levelColor = getAlertLevelColor(alert.level); + return ( + + + + + + {alert.name || t("diary.tripDetail.unknownAlert")} + + + {alert.confirmed && ( + + {t("diary.tripDetail.confirmed")} + + )} + + + {formatTime(alert.time)} + + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + emptyContainer: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 24, + gap: 8, + }, + emptyText: { + fontSize: 14, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + list: { + gap: 10, + }, + alertItem: { + padding: 12, + borderRadius: 8, + }, + alertHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 4, + }, + alertInfo: { + flexDirection: "row", + alignItems: "center", + gap: 8, + flex: 1, + }, + alertName: { + fontSize: 14, + fontWeight: "600", + flex: 1, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + alertTime: { + fontSize: 12, + marginLeft: 26, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + confirmedBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + }, + confirmedText: { + color: "#FFFFFF", + fontSize: 11, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, +}); diff --git a/components/diary/TripDetailSections/BasicInfoSection.tsx b/components/diary/TripDetailSections/BasicInfoSection.tsx new file mode 100644 index 0000000..a94eb17 --- /dev/null +++ b/components/diary/TripDetailSections/BasicInfoSection.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { View, Text, StyleSheet, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useI18n } from "@/hooks/use-i18n"; +import { useThemeContext } from "@/hooks/use-theme-context"; + +interface BasicInfoSectionProps { + trip: Model.Trip; +} + +/** + * Displays basic trip information like ship, dates, ports + */ +export default function BasicInfoSection({ trip }: BasicInfoSectionProps) { + const { t } = useI18n(); + const { colors } = useThemeContext(); + + const formatDateTime = (dateStr?: string) => { + if (!dateStr) return "--"; + const date = new Date(dateStr); + return date.toLocaleString("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const infoItems = [ + { + icon: "boat" as const, + label: t("diary.tripDetail.shipId"), + value: trip.vms_id || "--", + }, + { + icon: "play-circle" as const, + label: t("diary.tripDetail.departureTime"), + value: formatDateTime(trip.departure_time), + }, + { + icon: "stop-circle" as const, + label: t("diary.tripDetail.arrivalTime"), + value: formatDateTime(trip.arrival_time), + }, + { + icon: "location" as const, + label: t("diary.tripDetail.departurePort"), + value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--", + }, + { + icon: "flag" as const, + label: t("diary.tripDetail.arrivalPort"), + value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--", + }, + { + icon: "map" as const, + label: t("diary.tripDetail.fishingGrounds"), + value: trip.fishing_ground_codes?.length > 0 + ? trip.fishing_ground_codes.join(", ") + : "--", + }, + ]; + + return ( + + + + + {t("diary.tripDetail.basicInfo")} + + + + {infoItems.map((item, index) => ( + + + + + {item.label} + + + + {item.value} + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + marginBottom: 16, + overflow: "hidden", + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 16, + paddingVertical: 14, + }, + title: { + fontSize: 16, + fontWeight: "600", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + content: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 12, + }, + infoItem: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + infoLabel: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + infoLabelText: { + fontSize: 13, + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, + infoValue: { + fontSize: 13, + fontWeight: "500", + fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + }, +}); diff --git a/components/diary/TripDetailSections/FishingLogsSection.tsx b/components/diary/TripDetailSections/FishingLogsSection.tsx new file mode 100644 index 0000000..dfd9d51 --- /dev/null +++ b/components/diary/TripDetailSections/FishingLogsSection.tsx @@ -0,0 +1,356 @@ +import React from "react"; +import { View, Text, StyleSheet, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThemeContext } from "@/hooks/use-theme-context"; +import { useI18n } from "@/hooks/use-i18n"; +import SectionCard from "./SectionCard"; + +interface FishingLogsSectionProps { + fishingLogs?: Model.FishingLog[] | null; +} + +export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSectionProps) { + const { t } = useI18n(); + const { colors } = useThemeContext(); + + const logs = fishingLogs || []; + + const formatDateTime = (date?: Date) => { + if (!date) return "--"; + const d = new Date(date); + return d.toLocaleString("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const formatCoord = (lat?: number, lon?: number) => { + if (lat === undefined || lon === undefined) return "--"; + return `${lat.toFixed(4)}°N, ${lon.toFixed(4)}°E`; + }; + + const getStatusLabel = (status?: number) => { + switch (status) { + case 0: + return { label: t("diary.tripDetail.logStatusPending"), color: "#FEF3C7", textColor: "#92400E" }; + case 1: + return { label: t("diary.tripDetail.logStatusActive"), color: "#DBEAFE", textColor: "#1E40AF" }; + case 2: + return { label: t("diary.tripDetail.logStatusCompleted"), color: "#D1FAE5", textColor: "#065F46" }; + default: + return { label: t("diary.tripDetail.logStatusUnknown"), color: "#F3F4F6", textColor: "#4B5563" }; + } + }; + + return ( + + {logs.length === 0 ? ( + + + + {t("diary.tripDetail.noFishingLogs")} + + + ) : ( + + {logs.map((log, index) => { + const status = getStatusLabel(log.status); + const catchCount = log.info?.length || 0; + + return ( + + {/* Header */} + + + + #{index + 1} + + + + + {status.label} + + + + + {/* Time Info */} + + + + + {t("diary.tripDetail.startTime")}: + + + {formatDateTime(log.start_at)} + + + + + + {t("diary.tripDetail.endTime")}: + + + {formatDateTime(log.end_at)} + + + + + {/* Location Info */} + + + + + {t("diary.tripDetail.startLocation")}: + + + {formatCoord(log.start_lat, log.start_lon)} + + + + + + {t("diary.tripDetail.haulLocation")}: + + + {formatCoord(log.haul_lat, log.haul_lon)} + + + + + {/* Catch Info */} + {catchCount > 0 && ( + + + + + {t("diary.tripDetail.catchInfo")} ({catchCount} {t("diary.tripDetail.species")}) + + + + {log.info?.slice(0, 3).map((fish, fishIndex) => ( + + + {fish.fish_name || t("diary.tripDetail.unknownFish")} + + + {fish.catch_number} {fish.catch_unit} + + + ))} + {catchCount > 3 && ( + + +{catchCount - 3} {t("diary.tripDetail.more")} + + )} + + + )} + + {/* Weather */} + {log.weather_description && ( + + + + {log.weather_description} + + + )} + + ); + })} + + )} + + ); +} + +const styles = StyleSheet.create({ + emptyContainer: { + alignItems: "center", + justifyContent: "center", + paddingVertical: 24, + gap: 8, + }, + emptyText: { + fontSize: 14, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + list: { + gap: 16, + }, + logItem: { + borderWidth: 1, + borderRadius: 10, + padding: 12, + }, + logHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 10, + }, + logIndex: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 4, + backgroundColor: "rgba(59, 130, 246, 0.1)", + }, + logIndexText: { + fontSize: 14, + fontWeight: "700", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + statusBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 4, + }, + statusText: { + fontSize: 12, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + timeRow: { + gap: 6, + marginBottom: 10, + }, + timeItem: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + timeLabel: { + fontSize: 12, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + timeValue: { + fontSize: 12, + fontWeight: "500", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + locationContainer: { + padding: 10, + borderRadius: 6, + gap: 6, + marginBottom: 10, + }, + locationItem: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + locationLabel: { + fontSize: 11, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + locationValue: { + fontSize: 11, + fontWeight: "500", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + catchContainer: { + marginBottom: 10, + }, + catchHeader: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginBottom: 6, + }, + catchLabel: { + fontSize: 13, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + catchList: { + gap: 4, + paddingLeft: 22, + }, + catchItem: { + flexDirection: "row", + justifyContent: "space-between", + }, + fishName: { + fontSize: 12, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + fishAmount: { + fontSize: 12, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + moreText: { + fontSize: 12, + fontWeight: "500", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + weatherRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + weatherText: { + fontSize: 12, + fontStyle: "italic", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, +}); diff --git a/components/diary/TripDetailSections/SectionCard.tsx b/components/diary/TripDetailSections/SectionCard.tsx new file mode 100644 index 0000000..d734b6e --- /dev/null +++ b/components/diary/TripDetailSections/SectionCard.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { + View, + Text, + StyleSheet, + Platform, + TouchableOpacity, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThemeContext } from "@/hooks/use-theme-context"; + +interface SectionCardProps { + title: string; + icon: keyof typeof Ionicons.glyphMap; + children: React.ReactNode; + count?: number; + collapsible?: boolean; + defaultExpanded?: boolean; +} + +export default function SectionCard({ + title, + icon, + children, + count, + collapsible = false, + defaultExpanded = true, +}: SectionCardProps) { + const { colors } = useThemeContext(); + const [expanded, setExpanded] = React.useState(defaultExpanded); + + const themedStyles = { + container: { + backgroundColor: colors.card, + borderColor: colors.separator, + }, + title: { + color: colors.text, + }, + count: { + color: colors.textSecondary, + backgroundColor: colors.backgroundSecondary, + }, + icon: colors.primary, + }; + + return ( + + collapsible && setExpanded(!expanded)} + activeOpacity={collapsible ? 0.7 : 1} + disabled={!collapsible} + > + + + {title} + {count !== undefined && ( + + + {count} + + + )} + + {collapsible && ( + + )} + + {expanded && {children}} + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + marginBottom: 16, + overflow: "hidden", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 14, + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + title: { + fontSize: 16, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + countBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + minWidth: 24, + alignItems: "center", + }, + countText: { + fontSize: 12, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + content: { + paddingHorizontal: 16, + paddingBottom: 16, + }, +}); diff --git a/components/diary/TripDetailSections/index.ts b/components/diary/TripDetailSections/index.ts new file mode 100644 index 0000000..98ba9ed --- /dev/null +++ b/components/diary/TripDetailSections/index.ts @@ -0,0 +1,4 @@ +export { default as SectionCard } from "./SectionCard"; +export { default as AlertsSection } from "./AlertsSection"; +export { default as FishingLogsSection } from "./FishingLogsSection"; +export { default as BasicInfoSection } from "./BasicInfoSection"; diff --git a/components/diary/TripFormModal/MaterialCostList.tsx b/components/diary/TripFormModal/MaterialCostList.tsx index e9dff4a..dedda1e 100644 --- a/components/diary/TripFormModal/MaterialCostList.tsx +++ b/components/diary/TripFormModal/MaterialCostList.tsx @@ -134,7 +134,7 @@ export default function MaterialCostList({ return ( - {t("diary.materialCostList")} + {t("trip.costTable.title")} {/* Cost Items List */} diff --git a/components/diary/TripFormModal/TripFormBody.tsx b/components/diary/TripFormModal/TripFormBody.tsx new file mode 100644 index 0000000..74d63c2 --- /dev/null +++ b/components/diary/TripFormModal/TripFormBody.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { View, StyleSheet } from "react-native"; +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 { FishingGear, TripCost } from "./index"; + +interface TripFormBodyProps { + mode: "add" | "edit" | "view"; + tripData?: Model.Trip; + // Form state + selectedShipId: string; + setSelectedShipId: (id: string) => void; + tripName: string; + setTripName: (name: string) => void; + fishingGears: FishingGear[]; + setFishingGears: (gears: FishingGear[]) => void; + tripCosts: TripCost[]; + setTripCosts: (costs: TripCost[]) => void; + startDate: Date | null; + setStartDate: (date: Date | null) => void; + endDate: Date | null; + setEndDate: (date: Date | null) => void; + departurePortId: number; + setDeparturePortId: (id: number) => void; + arrivalPortId: number; + setArrivalPortId: (id: number) => void; + fishingGroundCodes: string; + setFishingGroundCodes: (codes: string) => void; + // Callbacks + onAutoFill?: (tripData: Model.Trip, selectedThingId: string) => void; +} + +export default function TripFormBody({ + mode, + selectedShipId, + setSelectedShipId, + tripName, + setTripName, + fishingGears, + setFishingGears, + tripCosts, + setTripCosts, + startDate, + setStartDate, + endDate, + setEndDate, + departurePortId, + setDeparturePortId, + arrivalPortId, + setArrivalPortId, + fishingGroundCodes, + setFishingGroundCodes, + onAutoFill, +}: TripFormBodyProps) { + const isEditMode = mode === "edit"; + const isViewMode = mode === "view"; + const isReadOnly = isViewMode; + + return ( + + {/* Auto Fill Section - only show in add mode */} + {!isEditMode && !isViewMode && onAutoFill && ( + + )} + + {/* Ship Selector - disabled in edit and view mode */} + + + {/* Trip Name */} + + + {/* Fishing Gear List */} + + + {/* Trip Cost List */} + + + {/* Trip Duration */} + + + {/* Port Selector */} + + + {/* Fishing Ground Codes */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/components/diary/TripFormModal/index.tsx b/components/diary/TripFormModal/index.tsx index 5ca8706..ec43f1d 100644 --- a/components/diary/TripFormModal/index.tsx +++ b/components/diary/TripFormModal/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, @@ -15,15 +15,21 @@ import { 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 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 { 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 { @@ -38,32 +44,82 @@ interface TripFormModalProps { visible: boolean; onClose: () => void; onSuccess?: () => void; - mode?: 'add' | 'edit' | 'view'; + mode?: "add" | "edit"; tripData?: Model.Trip; } -export default function TripFormModal({ - visible, - onClose, +// 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', + 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 + 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; + 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(DEFAULT_FORM_STATE.fishingGears); + const [tripCosts, setTripCosts] = useState(DEFAULT_FORM_STATE.tripCosts); + const [startDate, setStartDate] = useState(DEFAULT_FORM_STATE.startDate); + const [endDate, setEndDate] = useState(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) { - // Open animation: fade overlay + slide content up Animated.parallel([ Animated.timing(fadeAnim, { toValue: 1, @@ -79,217 +135,90 @@ export default function TripFormModal({ } }, [visible, fadeAnim, slideAnim]); - // Form state - const [selectedShipId, setSelectedShipId] = useState(""); - const [tripName, setTripName] = useState(""); - const [fishingGears, setFishingGears] = useState([]); - const [tripCosts, setTripCosts] = useState([]); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); - const [departurePortId, setDeparturePortId] = useState(1); - const [arrivalPortId, setArrivalPortId] = useState(1); - const [fishingGroundCodes, setFishingGroundCodes] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - - // Pre-fill form when in edit or view mode + // Pre-fill form when in edit 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(", ")); - } + if (isEditMode && tripData && visible) { + fillFormWithTripData(tripData, "edit"); } - }, [isEditMode, isViewMode, tripData, visible, mode]); + }, [isEditMode, tripData, visible, fillFormWithTripData]); - 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); - } - }); - }; + // 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 close modal after successful submit - always resets form - const handleSuccessClose = () => { - // Reset animation values for next open + // 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); - - // Close modal immediately + slideAnim.setValue(Dimensions.get("window").height); onClose(); - - // Reset form after closing - setTimeout(() => { - setSelectedShipId(""); - setTripName(""); - setFishingGears([]); - setTripCosts([]); - setStartDate(null); - setEndDate(null); - setDeparturePortId(1); - setArrivalPortId(1); - setFishingGroundCodes(""); - }, 100); - }; + setTimeout(resetForm, 100); + }, [fadeAnim, slideAnim, onClose, resetForm]); // 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); + 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)); + }, + [] + ); - // 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 + // Validate form + const validateForm = useCallback((): boolean => { if (!selectedShipId) { Alert.alert(t("common.error"), t("diary.validation.shipRequired")); - return; + return false; } - - // Validate dates are required if (!startDate || !endDate) { Alert.alert(t("common.error"), t("diary.validation.datesRequired")); - return; + return false; } - - // Validate trip name is required if (!tripName.trim()) { Alert.alert(t("common.error"), t("diary.validation.tripNameRequired")); - return; + return false; } + return true; + }, [selectedShipId, startDate, endDate, tripName, t]); - // 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 = { + // Build API body + const buildApiBody = useCallback((): Model.TripAPIBody => { + return { thing_id: selectedShipId, name: tripName, - departure_time: startDate ? startDate.toISOString() : "", + departure_time: startDate?.toISOString() || "", departure_port_id: departurePortId, - arrival_time: endDate ? endDate.toISOString() : "", + arrival_time: endDate?.toISOString() || "", arrival_port_id: arrivalPortId, - fishing_ground_codes: fishingGroundCodesArray, + fishing_ground_codes: parseFishingGroundCodes(fishingGroundCodes), fishing_gears: fishingGears.map((gear) => ({ name: gear.name, number: gear.number, @@ -302,75 +231,69 @@ export default function TripFormModal({ 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 { - let response; - if (isEditMode && tripData) { - // Edit mode: call updateTrip - response = await updateTrip(tripData.id, apiBody); + await updateTrip(tripData.id, apiBody); } else { - // Add mode: call createTrip - response = await createTrip(selectedShipId, apiBody); + await createTrip(selectedShipId, apiBody); } - - // Check if response is successful (response exists) - - // Call onSuccess callback first (to refresh data) - if (onSuccess) { - onSuccess(); - } - - // Show success alert + + onSuccess?.(); 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)); + console.error( + isEditMode ? "Error updating trip:" : "Error creating trip:", + error + ); Alert.alert( - t("common.error"), + 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, - }, + 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 ( @@ -381,11 +304,11 @@ export default function TripFormModal({ onRequestClose={handleCancel} > - {/* Header */} @@ -394,39 +317,31 @@ export default function TripFormModal({ - {isViewMode - ? t("diary.viewTrip") - : isEditMode - ? t("diary.editTrip") - : t("diary.addTrip")} + {isEditMode ? t("diary.editTrip") : t("diary.addTrip")} {/* Content */} - + {/* Auto Fill Section - only show in add mode */} - {!isEditMode && !isViewMode && } + {!isEditMode && } - {/* Ship Selector - disabled in edit and view mode */} + {/* Ship Selector - disabled in edit mode */} {/* Trip Name */} - + {/* Fishing Gear List */} - + {/* Trip Cost List */} - + {/* Trip Duration */} {/* Port Selector */} @@ -443,51 +357,45 @@ export default function TripFormModal({ arrivalPortId={arrivalPortId} onDeparturePortChange={setDeparturePortId} onArrivalPortChange={setArrivalPortId} - disabled={isReadOnly} /> {/* Fishing Ground Codes */} - {/* Footer - hide in view mode */} - {!isViewMode && ( - - - - {t("common.cancel")} + {/* Footer */} + + + + {t("common.cancel")} + + + + {isSubmitting ? ( + + ) : ( + + {isEditMode ? t("diary.saveChanges") : t("diary.createTrip")} - - - {isSubmitting ? ( - - ) : ( - - {isEditMode ? t("diary.saveChanges") : t("diary.createTrip")} - - )} - - - )} + )} + + @@ -505,10 +413,7 @@ const styles = StyleSheet.create({ borderTopRightRadius: 24, maxHeight: "90%", shadowColor: "#000", - shadowOffset: { - width: 0, - height: -4, - }, + shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.1, shadowRadius: 12, elevation: 8, diff --git a/constants/index.ts b/constants/index.ts index 487015c..c7a7a65 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -58,10 +58,11 @@ export const API_GET_ALL_BANZONES = "/api/sgw/banzones"; export const API_GET_SHIP_TYPES = "/api/sgw/ships/types"; export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup"; export const API_GET_LAST_TRIP = "/api/sgw/trips/last"; -export const API_POST_TRIP = "/api/sgw/trips"; +export const API_POST_TRIP = "/api/sgw/trips"; export const API_PUT_TRIP = "/api/sgw/trips"; export const API_GET_ALARM = "/api/alarms"; export const API_MANAGER_ALARM = "/api/alarms/confirm"; export const API_GET_ALL_SHIP = "/api/sgw/ships"; export const API_GET_ALL_PORT = "/api/sgw/ports"; export const API_GET_PHOTO = "/api/sgw/photo"; +export const API_GET_TRIP_CREW = "/api/sgw/trips/crews"; diff --git a/controller/TripCrewController.ts b/controller/TripCrewController.ts new file mode 100644 index 0000000..4c5fabc --- /dev/null +++ b/controller/TripCrewController.ts @@ -0,0 +1,12 @@ +import { api } from "@/config"; +import { API_GET_PHOTO, API_GET_TRIP_CREW } from "@/constants"; + +export async function queryTripCrew(tripId: string) { + return api.get(`${API_GET_TRIP_CREW}/${tripId}`); +} + +export async function queryCrewImage(personal_id: string) { + return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, { + responseType: "arraybuffer", + }); +} diff --git a/locales/en.json b/locales/en.json index 08d4f66..2b1e989 100644 --- a/locales/en.json +++ b/locales/en.json @@ -23,7 +23,8 @@ "theme": "Theme", "theme_light": "Light", "theme_dark": "Dark", - "theme_system": "System" + "theme_system": "System", + "retry": "Retry" }, "navigation": { "home": "Monitor", @@ -177,7 +178,44 @@ "viewTrip": "Trip Details", "saveChanges": "Save Changes", "updateTripSuccess": "Trip updated successfully!", - "updateTripError": "Unable to update trip. Please try again." + "updateTripError": "Unable to update trip. Please try again.", + "crew": { + "title": "Crew Members", + "loading": "Loading crew members...", + "fetchError": "Unable to load crew members. Please try again.", + "noCrewMembers": "No crew members in this trip yet", + "totalMembers": "Total: {{count}} members", + "member": "Crew Member", + "phone": "Phone", + "personalId": "ID Number", + "joinedAt": "Joined Date", + "leftAt": "Left Date", + "note": "Note", + "deleteConfirmTitle": "Delete Crew Member", + "deleteConfirmMessage": "Are you sure you want to remove {{name}} from this trip?", + "deleteSuccess": "Crew member removed successfully", + "roles": { + "captain": "Captain", + "crew": "Crew", + "engineer": "Engineer", + "cook": "Cook" + }, + "form": { + "addTitle": "Add Crew Member", + "editTitle": "Edit Crew Member", + "name": "Full Name", + "namePlaceholder": "Enter full name", + "nameRequired": "Please enter name", + "personalIdPlaceholder": "Enter ID number", + "personalIdRequired": "Please enter ID number", + "phonePlaceholder": "Enter phone number", + "role": "Role", + "address": "Address", + "addressPlaceholder": "Enter address", + "notePlaceholder": "Enter note (optional)", + "saveError": "Unable to save. Please try again." + } + } }, "trip": { "infoTrip": "Trip Information", diff --git a/locales/vi.json b/locales/vi.json index 0c98628..ea37bd8 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -23,7 +23,8 @@ "theme": "Giao diện", "theme_light": "Sáng", "theme_dark": "Tối", - "theme_system": "Hệ thống" + "theme_system": "Hệ thống", + "retry": "Thử lại" }, "navigation": { "home": "Giám sát", @@ -177,7 +178,87 @@ "viewTrip": "Chi tiết chuyến đi", "saveChanges": "Lưu thay đổi", "updateTripSuccess": "Cập nhật chuyến đi thành công!", - "updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại." + "updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.", + "crew": { + "title": "Danh sách thuyền viên", + "loading": "Đang tải danh sách thuyền viên...", + "fetchError": "Không thể tải danh sách thuyền viên. Vui lòng thử lại.", + "noCrewMembers": "Chưa có thuyền viên trong chuyến đi này", + "totalMembers": "Tổng cộng: {{count}} thuyền viên", + "member": "Thuyền viên", + "phone": "Điện thoại", + "personalId": "CMND/CCCD", + "joinedAt": "Ngày tham gia", + "leftAt": "Ngày rời đi", + "note": "Ghi chú", + "deleteConfirmTitle": "Xóa thuyền viên", + "deleteConfirmMessage": "Bạn có chắc chắn muốn xóa {{name}} khỏi chuyến đi này?", + "deleteSuccess": "Đã xóa thuyền viên thành công", + "roles": { + "captain": "Thuyền trưởng", + "crew": "Thuyền viên", + "engineer": "Kỹ sư", + "cook": "Đầu bếp" + }, + "form": { + "addTitle": "Thêm thuyền viên", + "editTitle": "Chỉnh sửa thuyền viên", + "name": "Họ và tên", + "namePlaceholder": "Nhập họ và tên", + "nameRequired": "Vui lòng nhập họ tên", + "personalIdPlaceholder": "Nhập số CMND/CCCD", + "personalIdRequired": "Vui lòng nhập số CMND/CCCD", + "phonePlaceholder": "Nhập số điện thoại", + "role": "Chức vụ", + "address": "Địa chỉ", + "addressPlaceholder": "Nhập địa chỉ", + "notePlaceholder": "Nhập ghi chú (nếu có)", + "saveError": "Không thể lưu thông tin. Vui lòng thử lại." + } + }, + "tripDetail": { + "title": "Chi tiết chuyến đi", + "notFound": "Không tìm thấy thông tin chuyến đi", + "basicInfo": "Thông tin cơ bản", + "shipId": "Mã tàu VMS", + "departureTime": "Thời gian khởi hành", + "arrivalTime": "Thời gian về bến", + "departurePort": "Cảng khởi hành", + "arrivalPort": "Cảng cập bến", + "fishingGrounds": "Ngư trường", + "alerts": "Danh sách cảnh báo", + "noAlerts": "Không có cảnh báo", + "unknownAlert": "Cảnh báo không xác định", + "confirmed": "Đã xác nhận", + "costs": "Chi phí chuyến đi", + "noCosts": "Chưa có chi phí", + "unknownCost": "Chi phí không xác định", + "totalCost": "Tổng chi phí", + "gears": "Danh sách ngư cụ", + "noGears": "Chưa có ngư cụ", + "unknownGear": "Ngư cụ không xác định", + "quantity": "Số lượng", + "crew": "Danh sách thuyền viên", + "noCrew": "Chưa có thuyền viên", + "unknownCrew": "Thuyền viên không xác định", + "roleCaptain": "Thuyền trưởng", + "roleCrew": "Thuyền viên", + "roleEngineer": "Kỹ sư", + "fishingLogs": "Danh sách mẻ lưới", + "noFishingLogs": "Chưa có mẻ lưới", + "startTime": "Bắt đầu", + "endTime": "Kết thúc", + "startLocation": "Vị trí thả", + "haulLocation": "Vị trí kéo", + "catchInfo": "Sản lượng", + "species": "loài", + "unknownFish": "Cá không xác định", + "more": "loài khác", + "logStatusPending": "Chờ xử lý", + "logStatusActive": "Đang thực hiện", + "logStatusCompleted": "Hoàn thành", + "logStatusUnknown": "Không xác định" + } }, "trip": { "infoTrip": "Thông Tin Chuyến Đi", diff --git a/utils/tripDataConverters.ts b/utils/tripDataConverters.ts new file mode 100644 index 0000000..82bda0d --- /dev/null +++ b/utils/tripDataConverters.ts @@ -0,0 +1,54 @@ +import { FishingGear, TripCost } from "@/components/diary/TripFormModal"; + +/** + * Convert Model.FishingGear[] to component format with id + */ +export function convertFishingGears( + gears: Model.FishingGear[] | undefined, + prefix: string = "gear" +): FishingGear[] { + if (!gears || !Array.isArray(gears)) return []; + + return gears.map((gear, index) => ({ + id: `${prefix}-${Date.now()}-${index}`, + name: gear.name || "", + number: gear.number?.toString() || "", + })); +} + +/** + * Convert Model.TripCost[] to component format with id + */ +export function convertTripCosts( + costs: Model.TripCost[] | undefined, + prefix: string = "cost" +): TripCost[] { + if (!costs || !Array.isArray(costs)) return []; + + return costs.map((cost, index) => ({ + id: `${prefix}-${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, + })); +} + +/** + * Convert fishing ground codes array to comma-separated string + */ +export function convertFishingGroundCodes(codes: number[] | undefined): string { + if (!codes || !Array.isArray(codes)) return ""; + return codes.join(", "); +} + +/** + * Parse comma-separated string to number array + */ +export function parseFishingGroundCodes(codesString: string): number[] { + return codesString + .split(",") + .map((code) => parseInt(code.trim())) + .filter((code) => !isNaN(code)); +}