diff --git a/app/(tabs)/diary.tsx b/app/(tabs)/diary.tsx index 9ca7db4..b11a74f 100644 --- a/app/(tabs)/diary.tsx +++ b/app/(tabs)/diary.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, + Alert, FlatList, Platform, StyleSheet, @@ -20,6 +21,7 @@ import { useTripsList } from "@/state/use-tripslist"; import dayjs from "dayjs"; import { useI18n } from "@/hooks/use-i18n"; import { useThemeContext } from "@/hooks/use-theme-context"; +import { useShip } from "@/state/use-ship"; export default function diary() { const { t } = useI18n(); @@ -42,10 +44,6 @@ export default function diary() { 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 = { offset: 0, @@ -57,13 +55,21 @@ export default function diary() { }, }; - // Gọi API things - const { getThings } = useThings(); + // Gọi API things nếu chưa có dữ liệu + const { things, getThings } = useThings(); useEffect(() => { - if (hasInitializedThings.current) return; - hasInitializedThings.current = true; - getThings(payloadThings); - }, []); + if (!things) { + getThings(payloadThings); + } + }, [things, getThings]); + + // Gọi API ships nếu chưa có dữ liệu + const { ships, getShip } = useShip(); + useEffect(() => { + if (!ships) { + getShip(); + } + }, [ships, getShip]); // State cho payload trips const [payloadTrips, setPayloadTrips] = useState({ @@ -88,9 +94,8 @@ export default function diary() { // Gọi API trips lần đầu useEffect(() => { - if (hasInitializedTrips.current) return; - hasInitializedTrips.current = true; - isInitialLoad.current = true; + if (!isInitialLoad.current) return; + isInitialLoad.current = false; setAllTrips([]); setHasMore(true); getTripsList(payloadTrips); @@ -182,23 +187,12 @@ export default function diary() { getTripsList(updatedPayload); }, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]); - // const handleTripPress = (tripId: string) => { - // // TODO: Navigate to trip detail - // console.log("Trip pressed:", tripId); - // }; - const handleViewTrip = (tripId: string) => { - // Navigate to trip detail page instead of opening modal - const tripToView = allTrips.find((trip) => trip.id === tripId); - if (tripToView) { - router.push({ - pathname: "/trip-detail", - params: { - tripId: tripToView.id, - tripData: JSON.stringify(tripToView), - }, - }); - } + // Navigate to trip detail page - chỉ truyền tripId + router.push({ + pathname: "/trip-detail", + params: { tripId }, + }); }; const handleEditTrip = (tripId: string) => { @@ -215,20 +209,107 @@ export default function diary() { if (trip) { router.push({ pathname: "/trip-crew", - params: { tripId: trip.id, tripName: trip.name || "" }, + params: { + tripId: trip.id, + tripName: trip.name || "", + tripStatus: String(trip.trip_status ?? ""), // trip_status là số + }, }); } }; - const handleSendTrip = (tripId: string) => { - console.log("Send trip:", tripId); - // TODO: Send trip for approval - }; + const handleSendTrip = useCallback( + async (tripId: string) => { + try { + // Import dynamically để tránh circular dependency + const { tripApproveRequest } = await import( + "@/controller/TripController" + ); - const handleDeleteTrip = (tripId: string) => { - console.log("Delete trip:", tripId); - // TODO: Show confirmation dialog and delete trip - }; + // Gọi API gửi yêu cầu phê duyệt + await tripApproveRequest(tripId); + console.log("✅ Send trip for approval:", tripId); + + // Reload danh sách để cập nhật trạng thái + isInitialLoad.current = true; + setAllTrips([]); + setHasMore(true); + const resetPayload: Model.TripListBody = { + ...payloadTrips, + offset: 0, + }; + setPayloadTrips(resetPayload); + getTripsList(resetPayload); + + // Scroll lên đầu + setTimeout(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, 100); + } catch (error) { + console.error("❌ Error sending trip for approval:", error); + } + }, + [payloadTrips, getTripsList] + ); + + const handleDeleteTrip = useCallback( + (tripId: string) => { + Alert.alert( + t("diary.cancelTripConfirmTitle") || "Xác nhận hủy chuyến đi", + t("diary.cancelTripConfirmMessage") || + "Bạn có chắc chắn muốn hủy chuyến đi này?", + [ + { + text: t("common.cancel") || "Hủy", + style: "cancel", + }, + { + text: t("common.confirm") || "Xác nhận", + style: "destructive", + onPress: async () => { + try { + // Import dynamically để tránh circular dependency + const { tripCancelRequest } = await import( + "@/controller/TripController" + ); + + // Gọi API hủy chuyến đi + await tripCancelRequest(tripId); + console.log("✅ Trip cancelled:", tripId); + + // Reload danh sách để cập nhật trạng thái + isInitialLoad.current = true; + setAllTrips([]); + setHasMore(true); + const resetPayload: Model.TripListBody = { + ...payloadTrips, + offset: 0, + }; + setPayloadTrips(resetPayload); + getTripsList(resetPayload); + + // Scroll lên đầu + setTimeout(() => { + flatListRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }); + }, 100); + } catch (error) { + console.error("❌ Error cancelling trip:", error); + Alert.alert( + t("common.error") || "Lỗi", + t("diary.cancelTripError") || + "Không thể hủy chuyến đi. Vui lòng thử lại." + ); + } + }, + }, + ] + ); + }, + [payloadTrips, getTripsList, t] + ); // Handle sau khi thêm chuyến đi thành công const handleTripAddSuccess = useCallback(() => { @@ -249,6 +330,24 @@ export default function diary() { }, 100); }, [payloadTrips, getTripsList]); + // Handle reload - gọi lại API + const handleReload = useCallback(() => { + isInitialLoad.current = true; + setAllTrips([]); + setHasMore(true); + const resetPayload: Model.TripListBody = { + ...payloadTrips, + offset: 0, + }; + setPayloadTrips(resetPayload); + getTripsList(resetPayload); + + // Scroll FlatList lên đầu + setTimeout(() => { + flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); + }, 100); + }, [payloadTrips, getTripsList]); + // Dynamic styles based on theme const themedStyles = { safeArea: { @@ -273,7 +372,6 @@ export default function diary() { ({ item }: { item: any }) => ( handleTripPress(item.id)} onView={() => handleViewTrip(item.id)} onEdit={() => handleEditTrip(item.id)} onTeam={() => handleViewTeam(item.id)} @@ -334,9 +432,26 @@ export default function diary() { > {/* Header */} - - {t("diary.title")} - + + + {t("diary.title")} + + + {loading && allTrips.length === 0 ? ( + + ) : ( + + )} + + {/* Filter & Add Button Row */} @@ -417,13 +532,25 @@ const styles = StyleSheet.create({ fontSize: 28, fontWeight: "700", lineHeight: 36, - marginBottom: 10, fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System", }), }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 10, + }, + reloadButton: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: "center", + alignItems: "center", + }, actionRow: { flexDirection: "row", justifyContent: "space-between", diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f499465..65a090e 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -211,7 +211,7 @@ export default function HomeScreen() { // console.log("No ZoneApproachingAlarm"); } if (entered.length > 0) { - console.log("ZoneEnteredAlarm: ", entered); + // console.log("ZoneEnteredAlarm: ", entered); } else { // console.log("No ZoneEnteredAlarm"); } diff --git a/app/trip-crew.tsx b/app/trip-crew.tsx index 7ffad55..9a712e1 100644 --- a/app/trip-crew.tsx +++ b/app/trip-crew.tsx @@ -7,6 +7,7 @@ import { TouchableOpacity, ActivityIndicator, Alert, + ScrollView, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -21,9 +22,10 @@ export default function TripCrewPage() { const { t } = useI18n(); const { colors } = useThemeContext(); const router = useRouter(); - const { tripId, tripName } = useLocalSearchParams<{ + const { tripId, tripName, tripStatus } = useLocalSearchParams<{ tripId: string; tripName?: string; + tripStatus?: string; }>(); // State @@ -104,9 +106,8 @@ export default function TripCrewPage() { ); }; - // Save crew handler - const handleSaveCrew = async (formData: any) => { - // TODO: Call API to add/edit crew when available + // Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách + const handleSaveCrew = async () => { await fetchCrewData(); }; @@ -146,13 +147,19 @@ export default function TripCrewPage() { )} - - - + {tripStatus === "0" && ( + + + + )} {/* Content */} - + {loading ? ( @@ -186,7 +193,7 @@ export default function TripCrewPage() { onDelete={handleDeleteCrew} /> )} - + {/* Footer - Crew count */} ); @@ -275,7 +283,10 @@ const styles = StyleSheet.create({ }, content: { flex: 1, + }, + scrollContent: { padding: 16, + paddingBottom: 20, }, loadingContainer: { flex: 1, diff --git a/app/trip-detail.tsx b/app/trip-detail.tsx index 9a2c36b..bee10c5 100644 --- a/app/trip-detail.tsx +++ b/app/trip-detail.tsx @@ -18,6 +18,9 @@ import { convertFishingGears, convertTripCosts, } from "@/utils/tripDataConverters"; +import { queryAlarms } from "@/controller/AlarmController"; +import { queryTripCrew } from "@/controller/TripCrewController"; +import { queryTripById } from "@/controller/TripController"; // Reuse existing components import CrewList from "@/components/diary/TripCrewModal/CrewList"; @@ -36,28 +39,77 @@ export default function TripDetailPage() { const { t } = useI18n(); const { colors } = useThemeContext(); const router = useRouter(); - const { tripId, tripData: tripDataParam } = useLocalSearchParams<{ + const { tripId } = useLocalSearchParams<{ tripId: string; - tripData?: string; }>(); const [loading, setLoading] = useState(true); const [trip, setTrip] = useState(null); - const [alerts] = useState([]); // TODO: Fetch from API + const [alerts, setAlerts] = useState([]); + const [crews, setCrews] = useState([]); - // Parse trip data from params or fetch from API + // Fetch trip data từ API useEffect(() => { - if (tripDataParam) { + const fetchTripData = async () => { + if (!tripId) return; + + setLoading(true); try { - const parsedTrip = JSON.parse(tripDataParam) as Model.Trip; - setTrip(parsedTrip); - } catch (e) { - console.error("Error parsing trip data:", e); + const response = await queryTripById(tripId); + if (response.data) { + setTrip(response.data); + } + } catch (error) { + console.error("Lỗi khi tải thông tin chuyến đi:", error); + } finally { + setLoading(false); } - } - // TODO: Fetch trip detail from API using tripId if not passed via params - setLoading(false); - }, [tripDataParam, tripId]); + }; + + fetchTripData(); + }, [tripId]); + + // Fetch alarms cho chuyến đi dựa trên thing_id (vms_id) + useEffect(() => { + const fetchAlarms = async () => { + if (!trip?.vms_id) return; + + try { + const response = await queryAlarms({ + offset: 0, + limit: 100, + order: "time", + dir: "desc", + thing_id: trip.vms_id, + }); + + if (response.data?.alarms) { + setAlerts(response.data.alarms); + } + } catch (error) { + console.error("Lỗi khi tải alarms:", error); + } + }; + + fetchAlarms(); + }, [trip?.vms_id]); + + // Fetch danh sách thuyền viên + useEffect(() => { + const fetchCrews = async () => { + if (!tripId) return; + + try { + const response = await queryTripCrew(tripId); + // API trả về { trip_crews: [...] } + if (response.data?.trip_crews) { + setCrews(response.data.trip_crews); + } + } catch (error) {} + }; + + fetchCrews(); + }, [tripId]); // Convert trip data to component format using memoization const fishingGears = useMemo( @@ -72,11 +124,20 @@ export default function TripDetailPage() { const statusConfig = useMemo(() => { const status = trip?.trip_status ?? 0; - return TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] || TRIP_STATUS_CONFIG[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 }) => ( + const EmptySection = ({ + icon, + message, + }: { + icon: string; + message: string; + }) => ( @@ -88,7 +149,10 @@ export default function TripDetailPage() { // Render loading state if (loading) { return ( - + @@ -102,10 +166,21 @@ export default function TripDetailPage() { // Render error state if (!trip) { return ( - -
router.back()} colors={colors} /> + +
router.back()} + colors={colors} + /> - + {t("diary.tripDetail.notFound")} @@ -115,19 +190,44 @@ export default function TripDetailPage() { } return ( - + {/* Header with status badge */} - - router.back()} style={styles.backButton}> + + router.back()} + style={styles.backButton} + > - + {trip.name || t("diary.tripDetail.title")} - - - + + + {statusConfig.label} @@ -157,10 +257,18 @@ export default function TripDetailPage() { > {tripCosts.length > 0 ? ( - {}} disabled hideTitle /> + {}} + disabled + hideTitle + /> ) : ( - + )} @@ -174,10 +282,18 @@ export default function TripDetailPage() { > {fishingGears.length > 0 ? ( - {}} disabled hideTitle /> + {}} + disabled + hideTitle + /> ) : ( - + )} @@ -185,14 +301,17 @@ export default function TripDetailPage() { - {trip.crews && trip.crews.length > 0 ? ( - + {crews.length > 0 ? ( + ) : ( - + )} @@ -214,7 +333,12 @@ function Header({ colors: any; }) { return ( - + @@ -238,7 +362,11 @@ const styles = StyleSheet.create({ }, loadingText: { fontSize: 14, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, header: { flexDirection: "row", @@ -259,7 +387,11 @@ const styles = StyleSheet.create({ title: { fontSize: 18, fontWeight: "700", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, statusBadge: { flexDirection: "row", @@ -272,7 +404,11 @@ const styles = StyleSheet.create({ statusText: { fontSize: 11, fontWeight: "600", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, placeholder: { width: 32, @@ -294,7 +430,11 @@ const styles = StyleSheet.create({ errorText: { fontSize: 14, textAlign: "center", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, sectionInnerContent: { marginTop: 5, @@ -307,6 +447,10 @@ const styles = StyleSheet.create({ }, emptyText: { fontSize: 14, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, }); diff --git a/components/diary/TripCard.tsx b/components/diary/TripCard.tsx index 7166028..8e03e47 100644 --- a/components/diary/TripCard.tsx +++ b/components/diary/TripCard.tsx @@ -9,13 +9,13 @@ import { import { Ionicons } from "@expo/vector-icons"; import { useTripStatusConfig } from "./types"; import { useThings } from "@/state/use-thing"; +import { useShip } from "@/state/use-ship"; import dayjs from "dayjs"; import { useI18n } from "@/hooks/use-i18n"; import { useThemeContext } from "@/hooks/use-theme-context"; interface TripCardProps { trip: Model.Trip; - onPress?: () => void; onView?: () => void; onEdit?: () => void; onTeam?: () => void; @@ -25,7 +25,6 @@ interface TripCardProps { export default function TripCard({ trip, - onPress, onView, onEdit, onTeam, @@ -35,6 +34,7 @@ export default function TripCard({ const { t } = useI18n(); const { colors } = useThemeContext(); const { things } = useThings(); + const { ships } = useShip(); const TRIP_STATUS_CONFIG = useTripStatusConfig(); // Tìm thing có id trùng với vms_id của trip @@ -42,6 +42,9 @@ export default function TripCard({ (thing) => thing.id === trip.vms_id ); + // Tìm ship để lấy reg_number + const shipOfTrip = ships?.find((s) => s.id === trip.ship_id); + // Lấy config status từ trip_status (number) const statusKey = trip.trip_status as keyof typeof TRIP_STATUS_CONFIG; const statusConfig = TRIP_STATUS_CONFIG[statusKey] || { @@ -54,7 +57,7 @@ export default function TripCard({ // Determine which actions to show based on status const showEdit = trip.trip_status === 0 || trip.trip_status === 1; const showSend = trip.trip_status === 0; - const showDelete = trip.trip_status === 1; + const showDelete = trip.trip_status === 1 || trip.trip_status === 2; const themedStyles = { card: { @@ -90,7 +93,9 @@ export default function TripCard({ color={statusConfig.textColor} /> - {trip.name} + + {trip.name} + - {t("diary.tripCard.shipName")} + + {t("diary.tripCard.shipName")} + {thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */} - {t("diary.tripCard.departure")} + + {t("diary.tripCard.shipCode")} + + + {shipOfTrip?.reg_number || "-"} + + + + + + {t("diary.tripCard.departure")} + {trip.departure_time ? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm") @@ -133,7 +151,9 @@ export default function TripCard({ - {t("diary.tripCard.return")} + + {t("diary.tripCard.return")} + {/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */} {trip.arrival_time @@ -153,7 +173,9 @@ export default function TripCard({ activeOpacity={0.7} > - {t("diary.tripCard.view")} + + {t("diary.tripCard.view")} + {showEdit && ( @@ -162,8 +184,14 @@ export default function TripCard({ onPress={onEdit} activeOpacity={0.7} > - - {t("diary.tripCard.edit")} + + + {t("diary.tripCard.edit")} + )} @@ -172,8 +200,14 @@ export default function TripCard({ onPress={onTeam} activeOpacity={0.7} > - - {t("diary.tripCard.team")} + + + {t("diary.tripCard.team")} + {showSend && ( @@ -182,8 +216,14 @@ export default function TripCard({ onPress={onSend} activeOpacity={0.7} > - - {t("diary.tripCard.send")} + + + {t("diary.tripCard.send")} + )} @@ -194,7 +234,9 @@ export default function TripCard({ activeOpacity={0.7} > - {t("diary.tripCard.delete")} + + {t("diary.tripCard.delete")} + )} diff --git a/components/diary/TripCrewModal/AddEditCrewModal.tsx b/components/diary/TripCrewModal/AddEditCrewModal.tsx index 7d032d6..7d68f46 100644 --- a/components/diary/TripCrewModal/AddEditCrewModal.tsx +++ b/components/diary/TripCrewModal/AddEditCrewModal.tsx @@ -17,15 +17,14 @@ import { import { Ionicons } from "@expo/vector-icons"; import { useI18n } from "@/hooks/use-i18n"; import { useThemeContext } from "@/hooks/use-theme-context"; -import { - searchCrew, - newTripCrew, - updateTripCrew, -} from "@/controller/TripCrewController"; + +import { newTripCrew, updateTripCrew } from "@/controller/TripCrewController"; import { newCrew, + searchCrew, updateCrewInfo, queryCrewImage, + uploadCrewImage, } from "@/controller/CrewController"; import * as ImagePicker from "expo-image-picker"; import { Buffer } from "buffer"; @@ -48,10 +47,11 @@ interface AddEditCrewModalProps { initialData?: Partial; tripId?: string; // Required for add mode to add crew to trip existingCrewIds?: string[]; // List of existing crew IDs in trip + tripStatus?: number; // Trạng thái chuyến đi để validate (type number) } const ROLES = ["captain", "crew"]; -const DEBOUNCE_DELAY = 1000; // 3 seconds debounce +const DEBOUNCE_DELAY = 1000; // 1 seconds debounce export default function AddEditCrewModal({ visible, @@ -61,6 +61,7 @@ export default function AddEditCrewModal({ initialData, tripId, existingCrewIds = [], + tripStatus, }: AddEditCrewModalProps) { const { t } = useI18n(); const { colors } = useThemeContext(); @@ -191,8 +192,24 @@ export default function AddEditCrewModal({ phone: person.phone || prev.phone, email: person.email || prev.email, address: person.address || prev.address, - note: person.note || prev.note, })); + + // Load avatar của thuyền viên đã tìm thấy + try { + setIsLoadingImage(true); + const imageResponse = await queryCrewImage(personalId.trim()); + if (imageResponse.data) { + const base64 = Buffer.from( + imageResponse.data as ArrayBuffer + ).toString("base64"); + setImageUri(`data:image/jpeg;base64,${base64}`); + } + } catch (imageError) { + // Không có ảnh - hiển thị placeholder + setImageUri(null); + } finally { + setIsLoadingImage(false); + } } else { setSearchStatus("not_found"); setFoundPersonData(null); @@ -337,16 +354,18 @@ export default function AddEditCrewModal({ try { if (mode === "add" && tripId) { // === CHẾ ĐỘ THÊM MỚI === + const isNewCrew = searchStatus === "not_found" || !foundPersonData; // Bước 1: Nếu không tìm thấy thuyền viên trong hệ thống -> tạo mới - if (searchStatus === "not_found" || !foundPersonData) { + // Note: KHÔNG gửi note vào newCrew vì note là riêng cho từng chuyến đi + if (isNewCrew) { await newCrew({ personal_id: formData.personalId.trim(), name: formData.name.trim(), phone: formData.phone || "", email: formData.email || "", birth_date: new Date(), - note: formData.note || "", + note: "", // Không gửi note - note là riêng cho từng chuyến đi address: formData.address || "", }); } @@ -361,21 +380,54 @@ export default function AddEditCrewModal({ // === CHẾ ĐỘ CHỈNH SỬA === // Bước 1: Cập nhật thông tin cá nhân của thuyền viên (không bao gồm note) - await updateCrewInfo(formData.personalId.trim(), { - name: formData.name.trim(), - phone: formData.phone || "", - email: formData.email || "", - birth_date: new Date(), - address: formData.address || "", - }); + try { + await updateCrewInfo(formData.personalId.trim(), { + name: formData.name.trim(), + phone: formData.phone || "", + email: formData.email || "", + birth_date: new Date(), + address: formData.address || "", + }); + console.log("✅ updateCrewInfo thành công"); + } catch (crewError: any) { + console.error("❌ Lỗi updateCrewInfo:", crewError.response?.data); + } - // Bước 2: Cập nhật role và note của thuyền viên trong chuyến đi - await updateTripCrew({ - trip_id: tripId, - personal_id: formData.personalId.trim(), - role: formData.role as "captain" | "crew", - note: formData.note || "", - }); + // Bước 2: Cập nhật role và note (chỉ khi chuyến đi chưa hoàn thành) + // TripStatus: 0=created, 1=pending, 2=approved, 3=active, 4=completed, 5=cancelled + if (tripStatus !== 4) { + try { + await updateTripCrew({ + trip_id: tripId, + personal_id: formData.personalId.trim(), + role: formData.role as "captain" | "crew", + note: formData.note || "", + }); + console.log("✅ updateTripCrew thành công"); + } catch (tripCrewError: any) { + console.error( + "❌ Lỗi updateTripCrew:", + tripCrewError.response?.data + ); + } + } else { + // Chuyến đi đã hoàn thành - không cho update role/note + console.log("⚠️ Chuyến đi đã hoàn thành - bỏ qua updateTripCrew"); + } + } + + // Upload ảnh nếu có ảnh mới được chọn + if (newImageUri && formData.personalId.trim()) { + try { + console.log("📤 Uploading image:", newImageUri); + await uploadCrewImage(formData.personalId.trim(), newImageUri); + console.log("✅ Upload ảnh thành công"); + } catch (uploadError: any) { + console.error("❌ Lỗi upload ảnh:", uploadError); + console.error("Response data:", uploadError.response?.data); + console.error("Response status:", uploadError.response?.status); + // Không throw error vì thông tin crew đã được lưu thành công + } } // Gọi callback để reload danh sách @@ -383,6 +435,8 @@ export default function AddEditCrewModal({ handleClose(); } catch (error: any) { console.error("Lỗi khi lưu thuyền viên:", error); + console.error("Response data:", error.response?.data); + console.error("Response status:", error.response?.status); Alert.alert(t("common.error"), t("diary.crew.form.saveError")); } finally { setIsSubmitting(false); @@ -507,6 +561,9 @@ export default function AddEditCrewModal({ {/* Ảnh thuyền viên */} @@ -661,21 +718,23 @@ export default function AddEditCrewModal({ /> - {/* Note */} - - - {t("diary.crew.note")} - - updateField("note", v)} - placeholder={t("diary.crew.form.notePlaceholder")} - placeholderTextColor={themedStyles.placeholder.color} - multiline - numberOfLines={3} - /> - + {/* Note - Chỉ hiển thị khi edit, ẩn khi add */} + {mode === "edit" && ( + + + {t("diary.crew.note")} + + updateField("note", v)} + placeholder={t("diary.crew.form.notePlaceholder")} + placeholderTextColor={themedStyles.placeholder.color} + multiline + numberOfLines={3} + /> + + )} {/* Footer */} @@ -849,7 +908,8 @@ const styles = StyleSheet.create({ footer: { flexDirection: "row", gap: 12, - padding: 20, + paddingHorizontal: 20, + paddingVertical: 20, borderTopWidth: 1, }, cancelButton: { diff --git a/components/diary/TripCrewModal/CrewList.tsx b/components/diary/TripCrewModal/CrewList.tsx index 3d9c0d3..f961cf6 100644 --- a/components/diary/TripCrewModal/CrewList.tsx +++ b/components/diary/TripCrewModal/CrewList.tsx @@ -1,11 +1,5 @@ import React from "react"; -import { - View, - Text, - StyleSheet, - FlatList, - Platform, -} from "react-native"; +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"; @@ -24,7 +18,7 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) { ); - const keyExtractor = (item: Model.TripCrews, index: number) => + const keyExtractor = (item: Model.TripCrews, index: number) => `${item.PersonalID}-${index}`; const renderEmpty = () => ( @@ -43,6 +37,8 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) { ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} contentContainerStyle={styles.listContent} + scrollEnabled={false} + nestedScrollEnabled /> ); } diff --git a/components/diary/TripCrewModal/index.tsx b/components/diary/TripCrewModal/index.tsx index 3d868ba..6c095cf 100644 --- a/components/diary/TripCrewModal/index.tsx +++ b/components/diary/TripCrewModal/index.tsx @@ -36,7 +36,9 @@ export default function TripCrewModal({ // 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; // State const [loading, setLoading] = useState(false); @@ -81,7 +83,7 @@ export default function TripCrewModal({ 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)) { @@ -144,7 +146,9 @@ export default function TripCrewModal({ onPress: async () => { // TODO: Call delete API when available // For now, just remove from local state - setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID)); + setCrews((prev) => + prev.filter((c) => c.PersonalID !== crew.PersonalID) + ); Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); }, }, @@ -152,10 +156,8 @@ export default function TripCrewModal({ ); }; - // 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 + // Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách + const handleSaveCrew = async () => { await fetchCrewData(); }; @@ -185,7 +187,10 @@ export default function TripCrewModal({ > {/* Header */} - + @@ -193,13 +198,19 @@ export default function TripCrewModal({ {t("diary.crew.title")} {tripName && ( - + {tripName} )} {/* Add Button */} - + @@ -209,21 +220,40 @@ export default function TripCrewModal({ {loading ? ( - + {t("diary.crew.loading")} ) : error ? ( - - + + {error} - {t("common.retry")} + + {t("common.retry")} + ) : ( @@ -238,7 +268,11 @@ export default function TripCrewModal({ {/* Footer - Crew count */} - + {t("diary.crew.totalMembers", { count: crews.length })} @@ -257,6 +291,8 @@ export default function TripCrewModal({ }} onSave={handleSaveCrew} mode={editingCrew ? "edit" : "add"} + tripId={tripId || undefined} + existingCrewIds={crews.map((c) => c.PersonalID)} initialData={ editingCrew ? { @@ -313,12 +349,20 @@ const styles = StyleSheet.create({ title: { fontSize: 18, fontWeight: "700", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, subtitle: { fontSize: 13, marginTop: 2, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, content: { flex: 1, @@ -332,7 +376,11 @@ const styles = StyleSheet.create({ }, loadingText: { fontSize: 14, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, errorContainer: { flex: 1, @@ -344,7 +392,11 @@ const styles = StyleSheet.create({ errorText: { fontSize: 14, textAlign: "center", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, retryButton: { marginTop: 8, @@ -356,7 +408,11 @@ const styles = StyleSheet.create({ color: "#FFFFFF", fontSize: 14, fontWeight: "600", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, footer: { borderTopWidth: 1, @@ -372,6 +428,10 @@ const styles = StyleSheet.create({ countText: { fontSize: 14, fontWeight: "600", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, }); diff --git a/components/diary/TripDetailSections/AlertsSection.tsx b/components/diary/TripDetailSections/AlertsSection.tsx index 8e0030d..2cfe7fd 100644 --- a/components/diary/TripDetailSections/AlertsSection.tsx +++ b/components/diary/TripDetailSections/AlertsSection.tsx @@ -14,15 +14,32 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) { const { colors } = useThemeContext(); const getAlertLevelColor = (level?: number) => { + const isDark = + colors.background === "#1C1C1E" || + colors.background === "#000000" || + colors.background?.toLowerCase().includes("1c1c1e"); + 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 + case 0: // Bình thường - Blue/Info + return isDark + ? { bg: "#172554", text: "#93C5FD" } // Dark blue + : { bg: "#DBEAFE", text: "#1E40AF" }; + case 1: // Warning - Yellow + return isDark + ? { bg: "#422006", text: "#FCD34D" } // Dark amber + : { bg: "#FEF3C7", text: "#92400E" }; + case 2: // Error - Red + return isDark + ? { bg: "#450A0A", text: "#FCA5A5" } // Dark red + : { bg: "#FEE2E2", text: "#991B1B" }; + case 3: // SOS - Critical Red + return isDark + ? { bg: "#7F1D1D", text: "#FFFFFF" } // Dark critical + : { bg: "#DC2626", text: "#FFFFFF" }; + default: // Default - Gray + return isDark + ? { bg: "#374151", text: "#D1D5DB" } // Dark gray + : { bg: "#F3F4F6", text: "#4B5563" }; } }; @@ -38,11 +55,16 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) { icon="warning-outline" count={alerts.length} collapsible - defaultExpanded={alerts.length > 0} + //defaultExpanded={alerts.length > 0} + defaultExpanded > {alerts.length === 0 ? ( - + {t("diary.tripDetail.noAlerts")} @@ -63,13 +85,22 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) { size={18} color={levelColor.text} /> - + {alert.name || t("diary.tripDetail.unknownAlert")} {alert.confirmed && ( - - {t("diary.tripDetail.confirmed")} + + + {t("diary.tripDetail.confirmed")} + )} diff --git a/components/diary/TripDetailSections/BasicInfoSection.tsx b/components/diary/TripDetailSections/BasicInfoSection.tsx index a94eb17..0e82315 100644 --- a/components/diary/TripDetailSections/BasicInfoSection.tsx +++ b/components/diary/TripDetailSections/BasicInfoSection.tsx @@ -1,8 +1,12 @@ -import React from "react"; +import React, { useEffect, useMemo } 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"; +import { useShip } from "@/state/use-ship"; +import { usePort } from "@/state/use-ports"; +import { useGroup } from "@/state/use-group"; +import { filterPortsByProvinceCode } from "@/utils/tripDataConverters"; interface BasicInfoSectionProps { trip: Model.Trip; @@ -15,6 +19,55 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) { const { t } = useI18n(); const { colors } = useThemeContext(); + // Get data from zustand stores + const { ships, getShip } = useShip(); + const { ports, getPorts } = usePort(); + const { groups, getUserGroups } = useGroup(); + + // Fetch data if not available + useEffect(() => { + if (!ships) { + getShip(); + } + }, [ships, getShip]); + + useEffect(() => { + if (!ports) { + getPorts(); + } + }, [ports, getPorts]); + + useEffect(() => { + if (!groups) { + getUserGroups(); + } + }, [groups, getUserGroups]); + + // Filter ports by province codes from groups + const filteredPorts = useMemo(() => { + return filterPortsByProvinceCode(ports, groups); + }, [ports, groups]); + + // Get ship name by ship_id + const shipName = useMemo(() => { + if (!trip?.ship_id || !ships) return "--"; + const ship = ships.find((s) => s.id === trip.ship_id); + return ship?.name || "--"; + }, [trip?.ship_id, ships]); + + // Get ship code (reg_number) by ship_id + const shipCode = useMemo(() => { + if (!trip?.ship_id || !ships) return "--"; + const ship = ships.find((s) => s.id === trip.ship_id); + return ship?.reg_number || "--"; + }, [trip?.ship_id, ships]); + + // Get port name by ID + const getPortName = (portId: number): string => { + const port = filteredPorts.find((p) => p.id === portId); + return port?.name || "--"; + }; + const formatDateTime = (dateStr?: string) => { if (!dateStr) return "--"; const date = new Date(dateStr); @@ -30,8 +83,13 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) { const infoItems = [ { icon: "boat" as const, - label: t("diary.tripDetail.shipId"), - value: trip.vms_id || "--", + label: t("diary.tripDetail.shipName"), + value: shipName, + }, + { + icon: "barcode" as const, + label: t("diary.tripDetail.shipCode"), + value: shipCode, }, { icon: "play-circle" as const, @@ -46,26 +104,36 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) { { icon: "location" as const, label: t("diary.tripDetail.departurePort"), - value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--", + value: getPortName(trip.departure_port_id), }, { icon: "flag" as const, label: t("diary.tripDetail.arrivalPort"), - value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--", + value: getPortName(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(", ") - : "--", + value: + trip.fishing_ground_codes?.length > 0 + ? trip.fishing_ground_codes.join(", ") + : "--", }, ]; return ( - + - + {t("diary.tripDetail.basicInfo")} @@ -74,8 +142,14 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) { {infoItems.map((item, index) => ( - - + + {item.label} @@ -106,7 +180,11 @@ const styles = StyleSheet.create({ title: { fontSize: 16, fontWeight: "600", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, content: { paddingHorizontal: 16, @@ -125,11 +203,19 @@ const styles = StyleSheet.create({ }, infoLabelText: { fontSize: 13, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, infoValue: { fontSize: 13, fontWeight: "500", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, }); diff --git a/components/diary/TripDetailSections/FishingLogsSection.tsx b/components/diary/TripDetailSections/FishingLogsSection.tsx index dfd9d51..994c659 100644 --- a/components/diary/TripDetailSections/FishingLogsSection.tsx +++ b/components/diary/TripDetailSections/FishingLogsSection.tsx @@ -9,7 +9,9 @@ interface FishingLogsSectionProps { fishingLogs?: Model.FishingLog[] | null; } -export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSectionProps) { +export default function FishingLogsSection({ + fishingLogs = [], +}: FishingLogsSectionProps) { const { t } = useI18n(); const { colors } = useThemeContext(); @@ -35,13 +37,29 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect const getStatusLabel = (status?: number) => { switch (status) { case 0: - return { label: t("diary.tripDetail.logStatusPending"), color: "#FEF3C7", textColor: "#92400E" }; + return { + label: t("diary.tripDetail.logStatusProcessing"), + color: "#FEF3C7", + textColor: "#92400E", + }; case 1: - return { label: t("diary.tripDetail.logStatusActive"), color: "#DBEAFE", textColor: "#1E40AF" }; + return { + label: t("diary.tripDetail.logStatusSuccess"), + color: "#D1FAE5", + textColor: "#065F46", + }; case 2: - return { label: t("diary.tripDetail.logStatusCompleted"), color: "#D1FAE5", textColor: "#065F46" }; + return { + label: t("diary.tripDetail.logStatusCancelled"), + color: "#FEE2E2", + textColor: "#B91C1C", + }; default: - return { label: t("diary.tripDetail.logStatusUnknown"), color: "#F3F4F6", textColor: "#4B5563" }; + return { + label: t("diary.tripDetail.logStatusUnknown"), + color: "#F3F4F6", + textColor: "#4B5563", + }; } }; @@ -55,7 +73,11 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect > {logs.length === 0 ? ( - + {t("diary.tripDetail.noFishingLogs")} @@ -65,7 +87,7 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect {logs.map((log, index) => { const status = getStatusLabel(log.status); const catchCount = log.info?.length || 0; - + return ( - + #{index + 1} - - + + {status.label} @@ -88,8 +119,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect {/* Time Info */} - - + + {t("diary.tripDetail.startTime")}: @@ -97,8 +137,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect - - + + {t("diary.tripDetail.endTime")}: @@ -108,22 +157,49 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect {/* Location Info */} - + - - + + {t("diary.tripDetail.startLocation")}: - + {formatCoord(log.start_lat, log.start_lon)} - - + + {t("diary.tripDetail.haulLocation")}: - + {formatCoord(log.haul_lat, log.haul_lon)} @@ -135,22 +211,33 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect - {t("diary.tripDetail.catchInfo")} ({catchCount} {t("diary.tripDetail.species")}) + {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.fish_name || + t("diary.tripDetail.unknownFish")} - + {fish.catch_number} {fish.catch_unit} ))} {catchCount > 3 && ( - + +{catchCount - 3} {t("diary.tripDetail.more")} )} @@ -161,8 +248,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect {/* Weather */} {log.weather_description && ( - - + + {log.weather_description} diff --git a/components/diary/TripFormModal/TripDurationPicker.tsx b/components/diary/TripFormModal/TripDurationPicker.tsx index b73ffdd..1eb0fd5 100644 --- a/components/diary/TripFormModal/TripDurationPicker.tsx +++ b/components/diary/TripFormModal/TripDurationPicker.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { View, Text, @@ -20,6 +20,8 @@ interface TripDurationPickerProps { disabled?: boolean; } +type PickerType = "startDate" | "startTime" | "endDate" | "endTime" | null; + export default function TripDurationPicker({ startDate, endDate, @@ -27,15 +29,27 @@ export default function TripDurationPicker({ onEndDateChange, disabled = false, }: TripDurationPickerProps) { - const { t } = useI18n(); + const { t, locale } = useI18n(); const { colors, colorScheme } = useThemeContext(); - const [showStartPicker, setShowStartPicker] = useState(false); - const [showEndPicker, setShowEndPicker] = useState(false); - + + // Single state for which picker is showing + const [activePicker, setActivePicker] = useState(null); + // Temp states to hold the picker value before confirming const [tempStartDate, setTempStartDate] = useState(new Date()); const [tempEndDate, setTempEndDate] = useState(new Date()); + // State hiển thị thời gian hiện tại + const [currentTime, setCurrentTime] = useState(new Date()); + + // Update current time every second + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + const formatDate = (date: Date | null) => { if (!date) return ""; const day = date.getDate().toString().padStart(2, "0"); @@ -44,62 +58,102 @@ export default function TripDurationPicker({ return `${day}/${month}/${year}`; }; - const handleOpenStartPicker = () => { + const formatTime = (date: Date | null) => { + if (!date) return ""; + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; + }; + + // Open start date picker + const handleOpenStartDatePicker = () => { const today = new Date(); const dateToUse = startDate || today; - // If no date selected, immediately set to today if (!startDate) { onStartDateChange(today); } - // Always set tempStartDate to the date we're using (today if no date was selected) setTempStartDate(dateToUse); - setShowStartPicker(true); + setActivePicker("startDate"); }; - const handleOpenEndPicker = () => { + // Open start time picker + const handleOpenStartTimePicker = () => { + const today = new Date(); + const dateToUse = startDate || today; + if (!startDate) { + onStartDateChange(today); + } + setTempStartDate(dateToUse); + setActivePicker("startTime"); + }; + + // Open end date picker + const handleOpenEndDatePicker = () => { const today = new Date(); const dateToUse = endDate || today; - // If no date selected, immediately set to today if (!endDate) { onEndDateChange(today); } - // Always set tempEndDate to the date we're using (today if no date was selected) setTempEndDate(dateToUse); - setShowEndPicker(true); + setActivePicker("endDate"); }; - const handleStartDateChange = (event: any, selectedDate?: Date) => { + // Open end time picker + const handleOpenEndTimePicker = () => { + const today = new Date(); + const dateToUse = endDate || today; + if (!endDate) { + onEndDateChange(today); + } + setTempEndDate(dateToUse); + setActivePicker("endTime"); + }; + + const handleStartPickerChange = (event: any, selectedDate?: Date) => { if (Platform.OS === "android") { - setShowStartPicker(false); + setActivePicker(null); if (event.type === "set" && selectedDate) { onStartDateChange(selectedDate); } } else if (selectedDate) { - // For iOS, update both temp and actual date immediately setTempStartDate(selectedDate); onStartDateChange(selectedDate); } }; - const handleEndDateChange = (event: any, selectedDate?: Date) => { + const handleEndPickerChange = (event: any, selectedDate?: Date) => { if (Platform.OS === "android") { - setShowEndPicker(false); + setActivePicker(null); if (event.type === "set" && selectedDate) { onEndDateChange(selectedDate); } } else if (selectedDate) { - // For iOS, update both temp and actual date immediately setTempEndDate(selectedDate); onEndDateChange(selectedDate); } }; - const handleConfirmStartDate = () => { - setShowStartPicker(false); + const handleConfirm = () => { + setActivePicker(null); }; - const handleConfirmEndDate = () => { - setShowEndPicker(false); + const handleCancel = () => { + setActivePicker(null); + }; + + const getPickerTitle = () => { + switch (activePicker) { + case "startDate": + return t("diary.selectStartDate"); + case "startTime": + return t("diary.selectStartTime") || "Chọn giờ khởi hành"; + case "endDate": + return t("diary.selectEndDate"); + case "endTime": + return t("diary.selectEndTime") || "Chọn giờ kết thúc"; + default: + return ""; + } }; const themedStyles = { @@ -114,133 +168,230 @@ export default function TripDurationPicker({ pickerHeader: { borderBottomColor: colors.border }, pickerTitle: { color: colors.text }, cancelButton: { color: colors.textSecondary }, + sectionCard: { + backgroundColor: colors.backgroundSecondary || colors.card, + borderColor: colors.border, + }, + sectionTitle: { color: colors.text }, }; - return ( - - - {t("diary.tripDuration")} - - - {/* Start Date */} - - - {t("diary.startDate")} + const renderDateTimeSection = ( + type: "start" | "end", + date: Date | null, + onOpenDate: () => void, + onOpenTime: () => void + ) => { + const isStart = type === "start"; + const icon = isStart ? "boat-outline" : "flag-outline"; + const title = isStart ? t("diary.startDate") : t("diary.endDate"); + const dateLabel = t("diary.date") || "Ngày"; + const timeLabel = t("diary.time") || "Giờ"; + + return ( + + {/* Section Header */} + + + + + + {title} + + + {/* Date and Time Row */} + + {/* Date Picker */} - - {startDate ? formatDate(startDate) : t("diary.selectDate")} - - {!disabled && ( + + + + {dateLabel} + + + {date ? formatDate(date) : t("diary.selectDate")} + + + + {!disabled && ( + )} - - {/* End Date */} - - - {t("diary.endDate")} - + {/* Time Picker */} - - {endDate ? formatDate(endDate) : t("diary.selectDate")} - + + + + + {timeLabel} + + + {date ? formatTime(date) : "--:--"} + + + {!disabled && ( )} + ); + }; - {/* Start Date Picker */} - {showStartPicker && ( - - - - - setShowStartPicker(false)}> - - {t("common.cancel")} - - - - {t("diary.selectStartDate")} - - - {t("common.done")} - - - - - - + const isStartPicker = + activePicker === "startDate" || activePicker === "startTime"; + const isTimePicker = + activePicker === "startTime" || activePicker === "endTime"; + + return ( + + + {t("diary.tripDuration")} + + + {/* Hiển thị thời gian hiện tại */} + + + + {t("diary.currentTime") || "Thời gian hiện tại"}: + + + {formatDate(currentTime)} {formatTime(currentTime)} + + + + {/* Start Section */} + {renderDateTimeSection( + "start", + startDate, + handleOpenStartDatePicker, + handleOpenStartTimePicker )} - {/* End Date Picker */} - {showEndPicker && ( - + {/* Connection Line */} + + + + + + + {/* End Section */} + {renderDateTimeSection( + "end", + endDate, + handleOpenEndDatePicker, + handleOpenEndTimePicker + )} + + {/* Unified Picker Modal */} + {activePicker && ( + - + - setShowEndPicker(false)}> - + + {t("common.cancel")} - {t("diary.selectEndDate")} + {getPickerTitle()} - + {t("common.done")} @@ -257,46 +408,100 @@ const styles = StyleSheet.create({ label: { fontSize: 16, fontWeight: "600", - marginBottom: 12, + marginBottom: 16, fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System", }), }, - subLabel: { - fontSize: 14, - marginBottom: 6, - fontFamily: Platform.select({ - ios: "System", - android: "Roboto", - default: "System", - }), + sectionCard: { + borderRadius: 12, + borderWidth: 1, + padding: 14, }, - dateRangeContainer: { + sectionHeader: { flexDirection: "row", - gap: 12, + alignItems: "center", + marginBottom: 12, }, - dateSection: { + iconContainer: { + width: 32, + height: 32, + borderRadius: 8, + justifyContent: "center", + alignItems: "center", + marginRight: 10, + }, + sectionTitle: { + fontSize: 15, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + dateTimeRow: { + flexDirection: "row", + gap: 10, + }, + dateTimeInput: { flex: 1, - }, - dateInput: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", borderWidth: 1, - borderRadius: 8, + borderRadius: 10, paddingHorizontal: 12, - paddingVertical: 12, + paddingVertical: 10, }, - dateText: { - fontSize: 15, + inputContent: { + flexDirection: "row", + alignItems: "center", + flex: 1, + }, + inputIcon: { + marginRight: 10, + }, + inputTextContainer: { + flex: 1, + }, + inputLabel: { + fontSize: 11, + marginBottom: 2, fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System", }), }, + inputValue: { + fontSize: 14, + fontWeight: "500", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + connectionContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingVertical: 8, + paddingHorizontal: 20, + }, + connectionLine: { + flex: 1, + height: 1, + }, + connectionDot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 8, + }, modalOverlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.5)", @@ -324,7 +529,7 @@ const styles = StyleSheet.create({ default: "System", }), }, - cancelButton: { + cancelButtonText: { fontSize: 16, fontFamily: Platform.select({ ios: "System", @@ -342,4 +547,29 @@ const styles = StyleSheet.create({ default: "System", }), }, + currentTimeContainer: { + flexDirection: "row", + alignItems: "center", + padding: 12, + borderRadius: 8, + marginBottom: 12, + gap: 8, + }, + currentTimeLabel: { + fontSize: 13, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + currentTimeValue: { + fontSize: 14, + fontWeight: "600", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, }); diff --git a/components/diary/TripFormModal/index.tsx b/components/diary/TripFormModal/index.tsx index 081573e..79c0571 100644 --- a/components/diary/TripFormModal/index.tsx +++ b/components/diary/TripFormModal/index.tsx @@ -227,6 +227,30 @@ export default function TripFormModal({ 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; @@ -237,7 +261,6 @@ export default function TripFormModal({ // Build API body const buildApiBody = useCallback((): Model.TripAPIBody => { return { - thing_id: selectedShipId, name: tripName, departure_time: startDate?.toISOString() || "", departure_port_id: departurePortId, @@ -257,7 +280,6 @@ export default function TripFormModal({ })), }; }, [ - selectedShipId, tripName, startDate, endDate, @@ -293,10 +315,28 @@ export default function TripFormModal({ isEditMode ? "Error updating trip:" : "Error creating trip:", error ); - Alert.alert( - t("common.error"), - isEditMode ? t("diary.updateTripError") : t("diary.createTripError") - ); + + // 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); } @@ -356,7 +396,10 @@ export default function TripFormModal({ {!isEditMode && } {/* Section 1: Basic Information */} - + {/* Ship Selector - disabled in edit mode */} {/* Section 2: Schedule & Location */} - + {/* Trip Duration */} {/* Section 3: Equipment */} - - + + {/* Section 4: Costs */} - - + + diff --git a/constants/index.ts b/constants/index.ts index 0d69044..2478843 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -40,13 +40,11 @@ export const STATUS_SOS = 3; export const API_PATH_LOGIN = "/api/tokens"; export const API_PATH_GET_PROFILE = "/api/users/profile"; export const API_PATH_SEARCH_THINGS = "/api/things/search"; -export const API_PATH_ENTITIES = "/api/io/entities"; export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo"; export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist"; export const API_GET_LAYER_INFO = "/api/sgw/geojson"; export const API_GET_TRIP = "/api/sgw/trip"; export const API_POST_TRIPSLIST = "api/sgw/tripslist"; -export const API_GET_ALARMS = "/api/io/alarms"; export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState"; export const API_HAUL_HANDLE = "/api/sgw/fishingLog"; export const API_GET_GPS = "/api/sgw/gps"; @@ -69,3 +67,6 @@ export const API_GET_TRIP_CREW = "/api/sgw/trips/crews"; export const API_SEARCH_CREW = "/api/sgw/trips/crew/"; export const API_TRIP_CREW = "/api/sgw/tripcrew"; export const API_CREW = "/api/sgw/crew"; +export const API_GET_TRIP_BY_ID = "/api/sgw/trips-by-id"; +export const API_TRIP_APPROVE_REQUEST = "/api/sgw/trips-approve-request"; +export const API_TRIP_CANCEL_REQUEST = "/api/sgw/trips-cancel-request"; diff --git a/controller/CrewController.ts b/controller/CrewController.ts index 9a9ab86..f3344ac 100644 --- a/controller/CrewController.ts +++ b/controller/CrewController.ts @@ -1,10 +1,14 @@ import { api } from "@/config"; -import { API_CREW, API_GET_PHOTO } from "@/constants"; +import { API_CREW, API_GET_PHOTO, API_SEARCH_CREW } from "@/constants"; export async function newCrew(body: Model.NewCrewAPIRequest) { return api.post(API_CREW, body); } +export async function searchCrew(personal_id: string) { + return api.get(`${API_SEARCH_CREW}/${personal_id}`); +} + export async function updateCrewInfo( personalId: string, body: Model.UpdateCrewAPIRequest @@ -17,3 +21,60 @@ export async function queryCrewImage(personal_id: string) { responseType: "arraybuffer", }); } + +// Upload ảnh thuyền viên +// Hỗ trợ các định dạng: HEIC, jpg, jpeg, png +export async function uploadCrewImage(personalId: string, imageUri: string) { + // Lấy tên file và extension từ URI + const uriParts = imageUri.split("/"); + const fileName = uriParts[uriParts.length - 1]; + + // Xác định MIME type dựa trên extension + const extension = fileName.split(".").pop()?.toLowerCase() || "jpg"; + let mimeType = "image/jpeg"; + + switch (extension) { + case "heic": + mimeType = "image/heic"; + break; + case "heif": + mimeType = "image/heif"; + break; + case "png": + mimeType = "image/png"; + break; + case "gif": + mimeType = "image/gif"; + break; + case "webp": + mimeType = "image/webp"; + break; + case "jpg": + case "jpeg": + default: + mimeType = "image/jpeg"; + break; + } + + // Tạo FormData để upload + const formData = new FormData(); + formData.append("file", { + uri: imageUri, + name: fileName, + type: mimeType, + } as any); + + // Debug logs + console.log("📤 Upload params:"); + console.log(" - URI:", imageUri); + console.log(" - fileName:", fileName); + console.log(" - mimeType:", mimeType); + console.log(" - endpoint:", `${API_GET_PHOTO}/people/${personalId}/main`); + + // Phải set Content-Type header cho React Native + return api.post(`${API_GET_PHOTO}/people/${personalId}/main`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); +} diff --git a/controller/DeviceController.ts b/controller/DeviceController.ts index 009e6b0..1f48008 100644 --- a/controller/DeviceController.ts +++ b/controller/DeviceController.ts @@ -19,10 +19,8 @@ export async function queryShipGroups() { return await api.get(API_GET_SHIP_GROUPS); } -export async function queryAllShips(params: Model.SearchThingBody) { - return await api.get(API_GET_ALL_SHIP, { - params: params, - }); +export async function queryAllShips() { + return await api.get(API_GET_ALL_SHIP); } export async function queryShipsImage(ship_id: string) { diff --git a/controller/TripController.ts b/controller/TripController.ts index 07915fa..e956b8f 100644 --- a/controller/TripController.ts +++ b/controller/TripController.ts @@ -9,12 +9,19 @@ import { API_POST_TRIP, API_PUT_TRIP, API_TRIP_CREW, + API_GET_TRIP_BY_ID, + API_TRIP_APPROVE_REQUEST, + API_TRIP_CANCEL_REQUEST, } from "@/constants"; export async function queryTrip() { return api.get(API_GET_TRIP); } +export async function queryTripById(tripId: string) { + return api.get(`${API_GET_TRIP_BY_ID}/${tripId}`); +} + export async function queryLastTrip(thingId: string) { return api.get(`${API_GET_LAST_TRIP}/${thingId}`); } @@ -42,3 +49,11 @@ export async function createTrip(thingId: string, body: Model.TripAPIBody) { export async function updateTrip(tripId: string, body: Model.TripAPIBody) { return api.put(`${API_PUT_TRIP}/${tripId}`, body); } + +export async function tripApproveRequest(tripId: string) { + return api.put(`${API_TRIP_APPROVE_REQUEST}/${tripId}`); +} + +export async function tripCancelRequest(tripId: string) { + return api.put(`${API_TRIP_CANCEL_REQUEST}/${tripId}`); +} diff --git a/controller/TripCrewController.ts b/controller/TripCrewController.ts index 5cac7cf..6fa178a 100644 --- a/controller/TripCrewController.ts +++ b/controller/TripCrewController.ts @@ -7,11 +7,9 @@ import { } from "@/constants"; export async function queryTripCrew(tripId: string) { - return api.get(`${API_GET_TRIP_CREW}/${tripId}`); -} - -export async function searchCrew(personal_id: string) { - return api.get(`${API_SEARCH_CREW}/${personal_id}`); + return api.get<{ trip_crews: Model.TripCrews[] }>( + `${API_GET_TRIP_CREW}/${tripId}` + ); } export async function newTripCrew(body: Model.NewTripCrewAPIRequest) { diff --git a/controller/typings.d.ts b/controller/typings.d.ts index 9d03d1f..8d21e14 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -234,7 +234,6 @@ declare namespace Model { // API body interface for creating a new trip interface TripAPIBody { - thing_id?: string; name: string; departure_time: string; // ISO string departure_port_id: number; @@ -372,6 +371,7 @@ declare namespace Model { dir?: "asc" | "desc"; name?: string; level?: number; + thing_id?: string; confirmed?: boolean; } diff --git a/locales/en.json b/locales/en.json index 6fa50c7..39a661c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3,6 +3,7 @@ "app_name": "Sea Gateway", "footer_text": "Product of Mobifone v1.0", "ok": "OK", + "confirm": "Confirm", "cancel": "Cancel", "done": "Done", "save": "Save", @@ -145,11 +146,16 @@ "costPerUnit": "Cost", "totalCost": "Total Cost", "tripDuration": "Trip Duration", - "startDate": "Start", - "endDate": "End", + "currentTime": "Current Time", + "startDate": "Departure", + "endDate": "Arrival", + "date": "Date", + "time": "Time", "selectDate": "Select Date", - "selectStartDate": "Select start date", - "selectEndDate": "Select end date", + "selectStartDate": "Select departure date", + "selectEndDate": "Select arrival date", + "selectStartTime": "Select departure time", + "selectEndTime": "Select arrival time", "portLabel": "Port", "departurePort": "Departure Port", "arrivalPort": "Arrival Port", @@ -178,15 +184,21 @@ "validation": { "shipRequired": "Please select a ship before creating the trip", "datesRequired": "Please select departure and arrival dates", - "tripNameRequired": "Please enter a trip name" + "tripNameRequired": "Please enter a trip name", + "startDateNotInPast": "Departure time cannot be in the past", + "endDateAfterStart": "Arrival time must be after departure time" }, "createTripSuccess": "Trip created successfully!", "createTripError": "Unable to create trip. Please try again.", + "tripAlreadyExistsError": "There is an ongoing trip that has not been completed. Please complete the current trip before creating a new one.", "editTrip": "Edit Trip", "viewTrip": "Trip Details", "saveChanges": "Save Changes", "updateTripSuccess": "Trip updated successfully!", "updateTripError": "Unable to update trip. Please try again.", + "cancelTripConfirmTitle": "Cancel Request Confirmation", + "cancelTripConfirmMessage": "Are you sure you want to cancel the approval request? The trip will be reset to initial status.", + "cancelTripError": "Unable to cancel request. Please try again.", "crew": { "title": "Crew Members", "loading": "Loading crew members...", @@ -234,7 +246,8 @@ "title": "Trip Details", "notFound": "Trip information not found", "basicInfo": "Basic Information", - "shipId": "VMS Ship Code", + "shipName": "Ship Name", + "shipCode": "Ship Code", "departureTime": "Departure Time", "arrivalTime": "Arrival Time", "departurePort": "Departure Port", @@ -268,9 +281,9 @@ "species": "species", "unknownFish": "Unknown fish", "more": "more species", - "logStatusPending": "Pending", - "logStatusActive": "Active", - "logStatusCompleted": "Completed", + "logStatusProcessing": "Processing", + "logStatusSuccess": "Complete", + "logStatusCancelled": "Cancelled", "logStatusUnknown": "Unknown" } }, diff --git a/locales/vi.json b/locales/vi.json index 77ca41c..59c49fd 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -3,6 +3,7 @@ "app_name": "Hệ thống giám sát tàu cá", "footer_text": "Sản phẩm của Mobifone v1.0", "ok": "OK", + "confirm": "Xác nhận", "cancel": "Hủy", "done": "Xong", "save": "Lưu", @@ -145,11 +146,16 @@ "costPerUnit": "Chi phí", "totalCost": "Tổng chi phí", "tripDuration": "Thời gian chuyến đi", - "startDate": "Bắt đầu", - "endDate": "Kết thúc", + "currentTime": "Thời gian hiện tại", + "startDate": "Khởi hành", + "endDate": "Cập bến", + "date": "Ngày", + "time": "Giờ", "selectDate": "Chọn ngày", - "selectStartDate": "Chọn ngày bắt đầu", - "selectEndDate": "Chọn ngày kết thúc", + "selectStartDate": "Chọn ngày khởi hành", + "selectEndDate": "Chọn ngày cập bến", + "selectStartTime": "Chọn giờ khởi hành", + "selectEndTime": "Chọn giờ cập bến", "portLabel": " Cảng", "departurePort": "Cảng khởi hành", "arrivalPort": "Cảng cập bến", @@ -178,15 +184,21 @@ "validation": { "shipRequired": "Vui lòng chọn tàu trước khi tạo chuyến đi", "datesRequired": "Vui lòng chọn ngày khởi hành và ngày kết thúc", - "tripNameRequired": "Vui lòng nhập tên chuyến đi" + "tripNameRequired": "Vui lòng nhập tên chuyến đi", + "startDateNotInPast": "Thời điểm khởi hành không được ở quá khứ", + "endDateAfterStart": "Thời điểm kết thúc phải sau thời điểm khởi hành" }, "createTripSuccess": "Tạo chuyến đi thành công!", "createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại.", + "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.", "editTrip": "Chỉnh sửa chuyến đi", "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.", + "cancelTripConfirmTitle": "Xác nhận hủy yêu cầu", + "cancelTripConfirmMessage": "Bạn có chắc chắn muốn hủy yêu cầu phê duyệt? Chuyến đi sẽ trở về trạng thái đã khởi tạo.", + "cancelTripError": "Không thể hủy yêu cầu. 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...", @@ -234,7 +246,8 @@ "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", + "shipName": "Tên tàu", + "shipCode": "Mã tàu", "departureTime": "Thời gian khởi hành", "arrivalTime": "Thời gian về bến", "departurePort": "Cảng khởi hành", @@ -268,9 +281,9 @@ "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", + "logStatusProcessing": "Đang đánh bắt", + "logStatusSuccess": "Đã kết thúc", + "logStatusCancelled": "Đã hủy", "logStatusUnknown": "Không xác định" } }, diff --git a/package-lock.json b/package-lock.json index ebf0188..45b0885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@types/react": "~19.1.0", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "prettier-plugin-tailwindcss": "^0.5.11", @@ -6555,9 +6556,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", - "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" diff --git a/package.json b/package.json index c296d61..1a2a0cc 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "devDependencies": { "@types/react": "~19.1.0", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "prettier-plugin-tailwindcss": "^0.5.11", diff --git a/state/use-ship.tsx b/state/use-ship.ts similarity index 92% rename from state/use-ship.tsx rename to state/use-ship.ts index 789a77c..5b9549b 100644 --- a/state/use-ship.tsx +++ b/state/use-ship.ts @@ -12,7 +12,7 @@ export const useShip = create((set) => ({ ships: null, getShip: async () => { try { - const response = await queryAllShips({}); + const response = await queryAllShips(); set({ ships: response.data?.ships, loading: false }); } catch (error) { console.error("Error when fetch Ship: ", error);