Compare commits

..

15 Commits

Author SHA1 Message Date
940e108e75 fix bug chọn thời gian khởi hành / kết thúc ( chuyến đi ) 2025-12-31 15:48:44 +07:00
88bdaeb3a6 Cập nhật validate có thuyển viên trong chuyến đi -> gửi chuyển đi 2025-12-30 17:10:18 +07:00
499bd76f5e Cập nhật thứ tự cảnh báo trong tab cảnh báo, sửa lại reload danh sách chuyến đi ( vuốt lên ) 2025-12-30 14:52:46 +07:00
bf261e70e4 Cập nhật theme cho cảnh báo, giám sát 2025-12-29 17:05:05 +07:00
871360af49 Cập nhật tab Nhật ký ( CRUD chuyến đi, CRUD thuyền viên trong chuyến đi ) 2025-12-29 15:56:47 +07:00
190e44b09e cập nhật modal CRUD thuyền viên trong trip 2025-12-24 21:58:18 +07:00
24847504b1 cập nhật thông tin cảng trong modal add/edit trip, tối ưu lại UI modal add/edit trip 2025-12-24 11:38:23 +07:00
000a4ed856 thêm tab "Xem chi tiết chuyến đi", "Xem chi tiết thành viên chuyến đi", tái sử dụng lại components modal tripForm 2025-12-23 23:10:19 +07:00
afc6acbfe2 cập nhật animation hiển thị modal, call API edit 2025-12-22 22:47:08 +07:00
67e9fc22a3 Cập nhật API thêm trip (validate) 2025-12-22 15:22:06 +07:00
Tran Anh Tuan
12fb7c48ed sửa lỗi form không reset, cập nhật thêm hàm gọi api khi thêm mới tàu 2025-12-10 20:05:34 +07:00
Tran Anh Tuan
35027a7e23 sửa lỗi image 2025-12-10 19:54:16 +07:00
Tran Anh Tuan
6af6749712 xóa package expo-image 2025-12-10 19:52:13 +07:00
Tran Anh Tuan
3e1c4dcbc5 thêm giao diện quản lý thuyền 2025-12-10 19:49:54 +07:00
Tran Anh Tuan
df4318fed4 thêm giao diện cảnh báo 2025-12-09 11:37:19 +07:00
74 changed files with 11392 additions and 2492 deletions

1494
ReactQuery_Axios.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,14 @@ import { Tabs, useSegments } from "expo-router";
import { HapticTab } from "@/components/haptic-tab"; import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme"; import { queryProfile } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n"; 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"; import { useEffect, useRef } from "react";
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const { colors, colorScheme } = useThemeContext();
const segments = useSegments() as string[]; const segments = useSegments() as string[];
const prev = useRef<string | null>(null); const prev = useRef<string | null>(null);
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null; const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
@@ -29,12 +30,38 @@ export default function TabLayout() {
} }
}, [currentSegment]); }, [currentSegment]);
useEffect(() => {
const getUserProfile = async () => {
try {
const resp = await queryProfile();
if (resp.data && resp.status === 200) {
await addUserStorage(
resp.data.id || "",
resp.data.metadata?.user_type || ""
);
}
} catch (error) {
console.error("Error when get Profile: ", error);
}
};
getUserProfile();
}, []);
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint, tabBarActiveTintColor: colors.tint,
headerShown: false, headerShown: false,
tabBarButton: HapticTab, tabBarButton: HapticTab,
// Set tab bar styles based on theme - prevents white flash
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.separator,
},
// Set screen content background
sceneStyle: {
backgroundColor: colors.background,
},
}} }}
> >
<Tabs.Screen <Tabs.Screen

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform, Platform,
StyleSheet, StyleSheet,
@@ -10,21 +11,25 @@ import {
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import FilterButton from "@/components/diary/FilterButton"; import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard"; import TripCard from "@/components/diary/TripCard";
import FilterModal, { FilterValues } from "@/components/diary/FilterModal"; import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
import AddTripModal from "@/components/diary/addTripModal"; import TripFormModal from "@/components/diary/TripFormModal";
import { useThings } from "@/state/use-thing"; import { useThings } from "@/state/use-thing";
import { useTripsList } from "@/state/use-tripslist"; import { useTripsList } from "@/state/use-tripslist";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
export default function diary() { export default function diary() {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const router = useRouter();
const [showFilterModal, setShowFilterModal] = useState(false); const [showFilterModal, setShowFilterModal] = useState(false);
const [showAddTripModal, setShowAddTripModal] = useState(false); const [showTripFormModal, setShowTripFormModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
const [filters, setFilters] = useState<FilterValues>({ const [filters, setFilters] = useState<FilterValues>({
status: null, status: null,
startDate: null, startDate: null,
@@ -36,7 +41,9 @@ export default function diary() {
const [allTrips, setAllTrips] = useState<any[]>([]); const [allTrips, setAllTrips] = useState<any[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
const flatListRef = useRef<FlatList>(null);
// Body call API things (đang fix cứng) // Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = { const payloadThings: Model.SearchThingBody = {
@@ -49,11 +56,21 @@ export default function diary() {
}, },
}; };
// Gọi API things // Gọi API things nếu chưa có dữ liệu
const { getThings } = useThings(); const { things, getThings } = useThings();
useEffect(() => { useEffect(() => {
if (!things) {
getThings(payloadThings); 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 // State cho payload trips
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({ const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
@@ -76,11 +93,10 @@ export default function diary() {
const { tripsList, getTripsList, loading } = useTripsList(); const { tripsList, getTripsList, loading } = useTripsList();
// console.log("Payload trips:", payloadTrips);
// Gọi API trips lần đầu // Gọi API trips lần đầu
useEffect(() => { useEffect(() => {
isInitialLoad.current = true; if (!isInitialLoad.current) return;
isInitialLoad.current = false;
setAllTrips([]); setAllTrips([]);
setHasMore(true); setHasMore(true);
getTripsList(payloadTrips); getTripsList(payloadTrips);
@@ -154,7 +170,11 @@ export default function diary() {
// Hàm load more data khi scroll đến cuối // Hàm load more data khi scroll đến cuối
const handleLoadMore = useCallback(() => { 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; return;
} }
@@ -166,37 +186,187 @@ export default function diary() {
}; };
setPayloadTrips(updatedPayload); setPayloadTrips(updatedPayload);
getTripsList(updatedPayload); getTripsList(updatedPayload);
}, [isLoadingMore, hasMore, payloadTrips]); }, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
const handleTripPress = (tripId: string) => {
// TODO: Navigate to trip detail
console.log("Trip pressed:", tripId);
};
const handleViewTrip = (tripId: string) => { const handleViewTrip = (tripId: string) => {
console.log("View trip:", tripId); // Navigate to trip detail page - chỉ truyền tripId
// TODO: Navigate to trip detail view router.push({
pathname: "/trip-detail",
params: { tripId },
});
}; };
const handleEditTrip = (tripId: string) => { const handleEditTrip = (tripId: string) => {
console.log("Edit trip:", tripId); // Find the trip from allTrips
// TODO: Navigate to trip edit screen const tripToEdit = allTrips.find((trip) => trip.id === tripId);
if (tripToEdit) {
setEditingTrip(tripToEdit);
setShowTripFormModal(true);
}
}; };
const handleViewTeam = (tripId: string) => { const handleViewTeam = (tripId: string) => {
console.log("View team:", tripId); const trip = allTrips.find((t) => t.id === tripId);
// TODO: Navigate to team management if (trip) {
router.push({
pathname: "/trip-crew",
params: {
tripId: trip.id,
tripName: trip.name || "",
tripStatus: String(trip.trip_status ?? ""), // trip_status là số
},
});
}
}; };
const handleSendTrip = (tripId: string) => { const handleSendTrip = useCallback(
console.log("Send trip:", tripId); async (tripId: string) => {
// TODO: Send trip for approval try {
}; // Import dynamically để tránh circular dependency
const { tripApproveRequest } = await import(
"@/controller/TripController"
);
const { queryTripCrew } = await import(
"@/controller/TripCrewController"
);
const handleDeleteTrip = (tripId: string) => { // Kiểm tra xem có thuyền viên không trước khi gửi phê duyệt
console.log("Delete trip:", tripId); const crewResponse = await queryTripCrew(tripId);
// TODO: Show confirmation dialog and delete trip const tripCrews = crewResponse.data?.trip_crews || [];
// Nếu response 204 hoặc không có thuyền viên
if (crewResponse.status === 204 || tripCrews.length === 0) {
Alert.alert(
t("diary.noCrewErrorTitle"),
t("diary.noCrewErrorMessage")
);
return;
}
// 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);
Alert.alert(
t("common.error") || "Lỗi",
t("diary.sendApprovalError") ||
"Không thể gửi yêu cầu phê duyệt. Vui lòng thử lại."
);
}
},
[payloadTrips, getTripsList, t]
);
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(() => {
// Reset về trang đầu và gọi lại API
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]);
// Handle refresh - pull-to-refresh
const handleRefresh = useCallback(() => {
setRefreshing(true);
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload).finally(() => {
setRefreshing(false);
});
}, [payloadTrips, getTripsList]);
// Dynamic styles based on theme // Dynamic styles based on theme
const themedStyles = { const themedStyles = {
@@ -222,7 +392,6 @@ export default function diary() {
({ item }: { item: any }) => ( ({ item }: { item: any }) => (
<TripCard <TripCard
trip={item} trip={item}
onPress={() => handleTripPress(item.id)}
onView={() => handleViewTrip(item.id)} onView={() => handleViewTrip(item.id)}
onEdit={() => handleEditTrip(item.id)} onEdit={() => handleEditTrip(item.id)}
onTeam={() => handleViewTeam(item.id)} onTeam={() => handleViewTeam(item.id)}
@@ -230,7 +399,13 @@ export default function diary() {
onDelete={() => handleDeleteTrip(item.id)} onDelete={() => handleDeleteTrip(item.id)}
/> />
), ),
[] [
handleViewTrip,
handleEditTrip,
handleViewTeam,
handleSendTrip,
handleDeleteTrip,
]
); );
// Key extractor cho FlatList // Key extractor cho FlatList
@@ -271,10 +446,17 @@ export default function diary() {
}; };
return ( return (
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}> <SafeAreaView
style={[styles.safeArea, themedStyles.safeArea]}
edges={["top"]}
>
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<Text style={[styles.titleText, themedStyles.titleText]}>{t("diary.title")}</Text> <View style={styles.headerRow}>
<Text style={[styles.titleText, themedStyles.titleText]}>
{t("diary.title")}
</Text>
</View>
{/* Filter & Add Button Row */} {/* Filter & Add Button Row */}
<View style={styles.actionRow}> <View style={styles.actionRow}>
@@ -289,7 +471,7 @@ export default function diary() {
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, themedStyles.addButton]} style={[styles.addButton, themedStyles.addButton]}
onPress={() => setShowAddTripModal(true)} onPress={() => setShowTripFormModal(true)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="add" size={20} color="#FFFFFF" /> <Ionicons name="add" size={20} color="#FFFFFF" />
@@ -304,6 +486,7 @@ export default function diary() {
{/* Trip List with FlatList */} {/* Trip List with FlatList */}
<FlatList <FlatList
ref={flatListRef}
data={allTrips} data={allTrips}
renderItem={renderTripItem} renderItem={renderTripItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@@ -311,6 +494,8 @@ export default function diary() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore} onEndReached={handleLoadMore}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={handleRefresh}
ListFooterComponent={renderFooter} ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty} ListEmptyComponent={renderEmpty}
removeClippedSubviews={true} removeClippedSubviews={true}
@@ -327,10 +512,16 @@ export default function diary() {
onApply={handleApplyFilters} onApply={handleApplyFilters}
/> />
{/* Add Trip Modal */} {/* Add/Edit Trip Modal */}
<AddTripModal <TripFormModal
visible={showAddTripModal} visible={showTripFormModal}
onClose={() => setShowAddTripModal(false)} onClose={() => {
setShowTripFormModal(false);
setEditingTrip(null);
}}
onSuccess={handleTripAddSuccess}
mode={editingTrip ? "edit" : "add"}
tripData={editingTrip || undefined}
/> />
</SafeAreaView> </SafeAreaView>
); );
@@ -348,25 +539,23 @@ const styles = StyleSheet.create({
fontSize: 28, fontSize: 28,
fontWeight: "700", fontWeight: "700",
lineHeight: 36, lineHeight: 36,
marginBottom: 10,
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
android: "Roboto", android: "Roboto",
default: "System", default: "System",
}), }),
}, },
actionRow: {
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: 12,
marginBottom: 12,
},
headerRow: { headerRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginTop: 20, marginBottom: 10,
},
actionRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 12, marginBottom: 12,
}, },
countText: { countText: {
@@ -383,7 +572,7 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, height: 40,
borderRadius: 8, borderRadius: 8,
gap: 6, gap: 6,
}, },

View File

@@ -10,6 +10,7 @@ import ShipSearchForm, {
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants"; import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
import { queryBanzoneById } from "@/controller/MapController"; import { queryBanzoneById } from "@/controller/MapController";
import { useAppTheme } from "@/hooks/use-app-theme";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { searchThingEventBus } from "@/services/device_events"; import { searchThingEventBus } from "@/services/device_events";
@@ -69,6 +70,7 @@ export default function HomeScreen() {
const [things, setThings] = useState<Model.ThingsResponse | null>(null); const [things, setThings] = useState<Model.ThingsResponse | null>(null);
const platform = usePlatform(); const platform = usePlatform();
const theme = useThemeContext().colorScheme; const theme = useThemeContext().colorScheme;
const { colors } = useAppTheme();
const scale = useRef(new Animated.Value(0)).current; const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current; const opacity = useRef(new Animated.Value(1)).current;
const [shipSearchFormData, setShipSearchFormData] = useState< const [shipSearchFormData, setShipSearchFormData] = useState<
@@ -85,7 +87,7 @@ export default function HomeScreen() {
// Alarm list animation // Alarm list animation
const screenHeight = Dimensions.get("window").height; const screenHeight = Dimensions.get("window").height;
const alarmListHeight = Math.round(screenHeight * 0.3); const alarmListHeight = Math.round(screenHeight * 0.4);
const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current; const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current;
const alarmOpacity = useRef(new Animated.Value(0)).current; const alarmOpacity = useRef(new Animated.Value(0)).current;
const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden
@@ -211,7 +213,7 @@ export default function HomeScreen() {
// console.log("No ZoneApproachingAlarm"); // console.log("No ZoneApproachingAlarm");
} }
if (entered.length > 0) { if (entered.length > 0) {
console.log("ZoneEnteredAlarm: ", entered); // console.log("ZoneEnteredAlarm: ", entered);
} else { } else {
// console.log("No ZoneEnteredAlarm"); // console.log("No ZoneEnteredAlarm");
} }
@@ -693,6 +695,7 @@ export default function HomeScreen() {
fontSize: 20, fontSize: 20,
textAlign: "center", textAlign: "center",
fontWeight: "600", fontWeight: "600",
color: colors.text,
}} }}
> >
Danh sách tàu thuyền Danh sách tàu thuyền
@@ -735,7 +738,7 @@ export default function HomeScreen() {
style={{ style={{
width: "50%", width: "50%",
height: 1, height: 1,
backgroundColor: "#E5E7EB", backgroundColor: colors.separator,
marginVertical: 8, marginVertical: 8,
borderRadius: 1, borderRadius: 1,
}} }}
@@ -766,22 +769,36 @@ export default function HomeScreen() {
zIndex: 10, zIndex: 10,
}} }}
> >
<View className="bg-white rounded-t-3xl shadow-md overflow-hidden h-full z-50"> <View
style={{
backgroundColor: colors.surface,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
height: "100%",
}}
>
<View className="flex-row items-center justify-between px-4 py-2"> <View className="flex-row items-center justify-between px-4 py-2">
<Text className="text-lg font-semibold"> <Text
style={{
fontSize: 18,
fontWeight: "600",
color: colors.text,
}}
>
Danh sách cảnh báo Danh sách cảnh báo
</Text> </Text>
<TouchableOpacity <TouchableOpacity
onPress={() => closeAlarmList()} onPress={() => closeAlarmList()}
style={{ padding: 6 }} style={{ padding: 6 }}
> >
<Ionicons name="close" size={20} color="#374151" /> <Ionicons name="close" size={20} color={colors.icon} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View <View
style={{ style={{
flex: 1, flex: 1,
backgroundColor: "#F9FAFB", backgroundColor: colors.backgroundSecondary,
zIndex: 50, zIndex: 50,
}} }}
> >

View File

@@ -1,35 +1,178 @@
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import DevicesScreen from "@/components/manager/devices";
import FleetsScreen from "@/components/manager/fleets";
import ShipsScreen from "@/components/manager/ships";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { Colors } from "@/config";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useEffect, useMemo, useRef, useState } from "react";
import { Animated, StyleSheet, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
export default function manager() { export default function manager() {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const [selected, setSelected] = useState<"ships" | "devices" | "fleets">(
"ships"
);
const [containerWidth, setContainerWidth] = useState(0);
const indicatorTranslate = useRef(new Animated.Value(0)).current;
const SEGMENT_COUNT = 3;
const indexMap: Record<string, number> = {
ships: 0,
devices: 1,
fleets: 2,
};
const SegmentButton = ({
label,
active,
onPress,
}: {
label: string;
active?: boolean;
onPress?: () => void;
}) => {
return ( return (
<SafeAreaView style={{ flex: 1 }}> <TouchableOpacity
<ScrollView contentContainerStyle={styles.scrollContent}> style={[styles.segmentButton, active && styles.segmentButtonActive]}
onPress={onPress}
activeOpacity={0.8}
>
<ThemedText
style={[styles.segmentText, active && styles.segmentTextActive]}
>
{label}
</ThemedText>
</TouchableOpacity>
);
};
useEffect(() => {
if (containerWidth <= 0) return;
const segmentWidth = containerWidth / SEGMENT_COUNT;
const toValue = indexMap[selected] * segmentWidth;
Animated.spring(indicatorTranslate, {
toValue,
useNativeDriver: true,
friction: 14,
tension: 100,
}).start();
}, [selected, containerWidth, indicatorTranslate]);
return (
<SafeAreaView style={{ flex: 1 }} edges={["top"]}>
<ThemedView style={styles.scrollContent}>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.titleText}>Quản tàu </Text> <ThemedView style={styles.header}>
<View
style={styles.segmentContainer}
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
>
{/* sliding indicator */}
{containerWidth > 0 && (
<Animated.View
style={[
styles.indicator,
{
width: Math.max(containerWidth / SEGMENT_COUNT - 8, 0),
transform: [{ translateX: indicatorTranslate }],
},
]}
/>
)}
<SegmentButton
label="Tàu"
active={selected === "ships"}
onPress={() => setSelected("ships")}
/>
<SegmentButton
label="Thiết bị"
active={selected === "devices"}
onPress={() => setSelected("devices")}
/>
<SegmentButton
label="Đội tàu"
active={selected === "fleets"}
onPress={() => setSelected("fleets")}
/>
</View> </View>
</ScrollView> </ThemedView>
<View style={styles.contentWrapper}>
{selected === "ships" && <ShipsScreen />}
{selected === "devices" && <DevicesScreen />}
{selected === "fleets" && <FleetsScreen />}
</View>
</View>
</ThemedView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
container: { container: {
alignItems: "center", alignItems: "center",
padding: 15, flex: 1,
}, },
titleText: {
fontSize: 32, header: {
fontWeight: "700", width: "100%",
lineHeight: 40, paddingVertical: 8,
marginBottom: 30, paddingHorizontal: 4,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
}); segmentContainer: {
flexDirection: "row",
backgroundColor: colors.backgroundSecondary,
borderRadius: 10,
padding: 4,
alignSelf: "stretch",
},
segmentButton: {
flex: 1,
paddingVertical: 8,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
},
segmentButtonActive: {
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
shadowColor: scheme === "dark" ? "transparent" : "#000",
shadowOpacity: scheme === "dark" ? 0 : 0.1,
shadowRadius: 4,
elevation: scheme === "dark" ? 0 : 2,
},
segmentText: {
fontSize: 14,
fontWeight: "600",
color: colors.textSecondary,
},
segmentTextActive: {
color: colors.text,
},
indicator: {
position: "absolute",
left: 4,
top: 4,
bottom: 4,
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
borderRadius: 8,
shadowColor: scheme === "dark" ? "transparent" : "#000",
shadowOpacity: scheme === "dark" ? 0 : 0.1,
shadowRadius: 4,
elevation: scheme === "dark" ? 0 : 2,
},
contentWrapper: {
flex: 1,
alignSelf: "stretch",
width: "100%",
},
});

View File

@@ -1,83 +0,0 @@
// import ButtonCancelTrip from "@/components/ButtonCancelTrip";
// import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
// import ButtonEndTrip from "@/components/ButtonEndTrip";
// import CrewListTable from "@/components/tripInfo/CrewListTable";
// import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
// import NetListTable from "@/components/tripInfo/NetListTable";
// import TripCostTable from "@/components/tripInfo/TripCostTable";
// import { useI18n } from "@/hooks/use-i18n";
// import { useThemeContext } from "@/hooks/use-theme-context";
// import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
// import { SafeAreaView } from "react-native-safe-area-context";
// export default function TripInfoScreen() {
// const { t } = useI18n();
// const { colors } = useThemeContext();
// return (
// <SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
// <View style={styles.header}>
// <Text style={[styles.titleText, { color: colors.text }]}>
// {t("trip.infoTrip")}
// </Text>
// <View style={styles.buttonWrapper}>
// <ButtonCreateNewHaulOrTrip />
// </View>
// </View>
// <ScrollView contentContainerStyle={styles.scrollContent}>
// <View style={styles.container}>
// <TripCostTable />
// <FishingToolsTable />
// <CrewListTable />
// <NetListTable />
// <View style={styles.buttonRow}>
// <ButtonCancelTrip />
// <ButtonEndTrip />
// </View>
// </View>
// </ScrollView>
// </SafeAreaView>
// );
// }
// const styles = StyleSheet.create({
// safeArea: {
// flex: 1,
// paddingBottom: 5,
// },
// scrollContent: {
// flexGrow: 1,
// },
// header: {
// width: "100%",
// paddingHorizontal: 15,
// paddingTop: 15,
// paddingBottom: 10,
// alignItems: "center",
// },
// buttonWrapper: {
// width: "100%",
// flexDirection: "row",
// justifyContent: "flex-end",
// },
// container: {
// alignItems: "center",
// paddingHorizontal: 15,
// },
// buttonRow: {
// flexDirection: "row",
// gap: 10,
// marginTop: 15,
// marginBottom: 15,
// },
// titleText: {
// fontSize: 32,
// fontWeight: "700",
// lineHeight: 40,
// paddingBottom: 10,
// fontFamily: Platform.select({
// ios: "System",
// android: "Roboto",
// default: "System",
// }),
// },
// });

View File

@@ -1,302 +1,251 @@
import { AlarmCard } from "@/components/alarm/AlarmCard";
import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import { queryAlarms } from "@/controller/AlarmController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs"; import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo } from "react"; import {
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native"; ActivityIndicator,
Animated,
FlatList,
LayoutAnimation,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { AlarmData } from ".";
// ============ Types ============ const PAGE_SIZE = 2;
type AlarmType = "approaching" | "entered" | "fishing";
interface AlarmCardProps { const WarningScreen = () => {
alarm: AlarmData; const [defaultAlarmParams, setDefaultAlarmParams] =
onPress?: () => void; useState<Model.AlarmPayload>({
} offset: 0,
limit: PAGE_SIZE,
order: "time",
dir: "desc",
});
const [alarms, setAlarms] = useState<Model.Alarm[]>([]);
const [loading, setLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [isShowSearchForm, setIsShowSearchForm] = useState(false);
const [formOpacity] = useState(new Animated.Value(0));
// ============ Config ============ const { colors } = useThemeContext();
const ALARM_CONFIG: Record<
AlarmType, const hasFilters = useMemo(() => {
{ return Boolean(
icon: keyof typeof Ionicons.glyphMap; (defaultAlarmParams as any)?.name ||
label: string; ((defaultAlarmParams as any)?.level !== undefined &&
bgColor: string; (defaultAlarmParams as any).level !== 0) ||
borderColor: string; (defaultAlarmParams as any)?.confirmed !== undefined
iconBgColor: string; );
iconColor: string; }, [defaultAlarmParams]);
labelColor: string;
useEffect(() => {
getAlarmsData(0, false);
}, []);
useEffect(() => {
if (isShowSearchForm) {
// Reset opacity to 0, then animate to 1
formOpacity.setValue(0);
Animated.timing(formOpacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
} else {
formOpacity.setValue(0);
} }
> = { }, [isShowSearchForm, formOpacity]);
entered: {
icon: "warning",
label: "Xâm nhập",
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconBgColor: "bg-red-100",
iconColor: "#DC2626",
labelColor: "text-red-600",
},
approaching: {
icon: "alert-circle",
label: "Tiếp cận",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
iconBgColor: "bg-amber-100",
iconColor: "#D97706",
labelColor: "text-amber-600",
},
fishing: {
icon: "fish",
label: "Đánh bắt",
bgColor: "bg-orange-50",
borderColor: "border-orange-200",
iconBgColor: "bg-orange-100",
iconColor: "#EA580C",
labelColor: "text-orange-600",
},
};
// ============ Helper Functions ============ const getAlarmsData = async (
const formatTimestamp = (timestamp?: number): string => { nextOffset = 0,
if (!timestamp) return "N/A"; append = false,
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss"); paramsOverride?: Model.AlarmPayload
}; ) => {
try {
if (append) setIsLoadingMore(true);
else setLoading(true);
// console.log("Call alarm with offset: ", nextOffset);
const usedParams = paramsOverride ?? defaultAlarmParams;
// console.log("params: ", usedParams);
// ============ AlarmCard Component ============ const resp = await queryAlarms({
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => { ...usedParams,
const config = ALARM_CONFIG[alarm.type]; offset: nextOffset,
});
const slice = resp.data?.alarms ?? [];
return ( // Sort alarms by level descending (higher level first: SOS > Danger > Warning > Info)
<TouchableOpacity // When appending, we need to sort the entire combined list, not just the new slice
onPress={onPress} setAlarms((prev) => {
activeOpacity={0.7} const combined = append ? [...prev, ...slice] : slice;
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`} return combined.sort((a, b) => (b.level ?? 0) - (a.level ?? 0));
> });
<View className="flex-row items-start gap-3"> setOffset(nextOffset);
{/* Icon Container */} setHasMore(nextOffset + PAGE_SIZE < resp.data?.total!);
<View } catch (error) {
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`} console.error("Cannot get Alarm Data: ", error);
> } finally {
<Ionicons name={config.icon} size={24} color={config.iconColor} /> setLoading(false);
</View> setIsLoadingMore(false);
setRefreshing(false);
}
};
{/* Content */} const handleAlarmReload = useCallback((onReload: boolean) => {
<View className="flex-1"> if (onReload) {
{/* Header: Ship name + Badge */} getAlarmsData(0, false, undefined);
<View className="flex-row items-center justify-between mb-1"> }
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
{alarm.ship_name || alarm.thing_id}
</ThemedText>
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
<ThemedText
className={`text-xs font-semibold ${config.labelColor}`}
>
{config.label}
</ThemedText>
</View>
</View>
{/* Zone Info */}
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
{alarm.zone.message || alarm.zone.zone_name}
</ThemedText>
{/* Footer: Zone ID + Time */}
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-1">
<Ionicons name="time-outline" size={20} color="#6B7280" />
<ThemedText className="text-xs text-gray-500">
{formatTimestamp(alarm.zone.gps_time)}
</ThemedText>
</View>
</View>
</View>
</View>
</TouchableOpacity>
);
});
AlarmCard.displayName = "AlarmCard";
// ============ Main Component ============
interface WarningScreenProps {
alarms?: AlarmData[];
}
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
// Mock data for demo - replace with actual props
const sampleAlarms: AlarmData[] = useMemo(
() => [
{
thing_id: "SHIP-001",
ship_name: "Ocean Star",
type: "entered",
zone: {
zone_type: 1,
zone_name: "Khu vực cấm A1",
zone_id: "A1",
message: "Tàu đã đi vào vùng cấm A1",
alarm_type: 1,
lat: 10.12345,
lon: 106.12345,
s: 12,
h: 180,
fishing: false,
gps_time: 1733389200,
},
},
{
thing_id: "SHIP-002",
ship_name: "Blue Whale",
type: "approaching",
zone: {
zone_type: 2,
zone_name: "Vùng cảnh báo B3",
zone_id: "B3",
message: "Tàu đang tiếp cận khu vực cấm B3",
alarm_type: 2,
lat: 9.87654,
lon: 105.87654,
gps_time: 1733389260,
},
},
{
thing_id: "SHIP-003",
ship_name: "Sea Dragon",
type: "fishing",
zone: {
zone_type: 3,
zone_name: "Vùng cấm đánh bắt C2",
zone_id: "C2",
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
alarm_type: 3,
lat: 11.11223,
lon: 107.44556,
fishing: true,
gps_time: 1733389320,
},
},
{
thing_id: "SHIP-004",
ship_name: "Red Coral",
type: "entered",
zone: {
zone_type: 1,
zone_name: "Khu vực A2",
zone_id: "A2",
message: "Tàu đã đi sâu vào khu vực A2",
alarm_type: 1,
gps_time: 1733389380,
},
},
{
thing_id: "SHIP-005",
ship_name: "Silver Wind",
type: "approaching",
zone: {
zone_type: 2,
zone_name: "Vùng B1",
zone_id: "B1",
message: "Tàu đang tiến gần vào vùng B1",
alarm_type: 2,
gps_time: 1733389440,
},
},
],
[]
);
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
const handleAlarmPress = useCallback((alarm: AlarmData) => {
console.log("Alarm pressed:", alarm);
// TODO: Navigate to alarm detail or show modal
}, []); }, []);
const renderAlarmCard = useCallback( const renderAlarmCard = useCallback(
({ item }: { item: AlarmData }) => ( ({ item }: { item: Model.Alarm }) => (
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} /> <AlarmCard alarm={item} onReload={handleAlarmReload} />
), ),
[handleAlarmPress] [handleAlarmReload]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback(
(item: AlarmData, index: number) => `${item.thing_id}-${index}`, (item: Model.Alarm, index: number) =>
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
[] []
); );
const ItemSeparator = useCallback(() => <View className="h-3" />, []); const handleLoadMore = useCallback(() => {
if (isLoadingMore || !hasMore) return;
const nextOffset = offset + PAGE_SIZE;
getAlarmsData(nextOffset, true);
}, [isLoadingMore, hasMore, offset]);
// Count alarms by type const handleRefresh = useCallback(() => {
const alarmCounts = useMemo(() => { setRefreshing(true);
return displayAlarms.reduce((acc, alarm) => { getAlarmsData(0, false, undefined);
acc[alarm.type] = (acc[alarm.type] || 0) + 1; }, []);
return acc;
}, {} as Record<AlarmType, number>); const onSearch = useCallback(
}, [displayAlarms]); (values: { name?: string; level?: number; confirmed?: boolean }) => {
const mapped = {
offset: 0,
limit: defaultAlarmParams.limit,
order: defaultAlarmParams.order,
dir: defaultAlarmParams.dir,
...(values.name && { name: values.name }),
...(values.level && values.level !== 0 && { level: values.level }),
...(values.confirmed !== undefined && { confirmed: values.confirmed }),
};
setDefaultAlarmParams(mapped);
// Call getAlarmsData with the mapped params directly so the
// request uses the updated params immediately (setState is async)
getAlarmsData(0, false, mapped);
toggleSearchForm();
},
[defaultAlarmParams]
);
const toggleSearchForm = useCallback(() => {
if (Platform.OS === "ios") {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}
if (isShowSearchForm) {
// Hide form
Animated.timing(formOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start(() => {
setIsShowSearchForm(false);
});
} else {
// Show form
setIsShowSearchForm(true);
Animated.timing(formOpacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [isShowSearchForm, formOpacity]);
return ( return (
<SafeAreaView style={styles.container} edges={["top"]}> <SafeAreaView style={styles.container} edges={["top"]}>
<ThemedView style={styles.content}> <ThemedView style={styles.content}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View className="flex-row items-center gap-3"> <View style={styles.headerLeft}>
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
<Ionicons name="warning" size={22} color="#fff" />
</View>
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText> <ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
</View> </View>
<View className="bg-red-500 px-3 py-1 rounded-full"> <View style={styles.badgeContainer}>
<ThemedText className="text-white text-sm font-semibold"> <TouchableOpacity onPress={toggleSearchForm}>
{displayAlarms.length} <Ionicons
</ThemedText> size={20}
name="filter-outline"
color={hasFilters ? colors.primary : colors.text}
/>
</TouchableOpacity>
</View> </View>
</View> </View>
{/* Stats Bar */} {/* Search Form */}
<View className="flex-row px-4 pb-3 gap-2"> {isShowSearchForm && (
{(["entered", "approaching", "fishing"] as AlarmType[]).map( <Animated.View style={{ opacity: formOpacity, zIndex: 100 }}>
(type) => { <AlarmSearchForm
const config = ALARM_CONFIG[type]; initialValue={{
const count = alarmCounts[type] || 0; name: defaultAlarmParams.name || "",
return ( level: defaultAlarmParams.level || 0,
<View confirmed: defaultAlarmParams.confirmed,
key={type} }}
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`} onSubmit={onSearch}
> onReset={toggleSearchForm}
<Ionicons
name={config.icon}
size={14}
color={config.iconColor}
/> />
<ThemedText </Animated.View>
className={`text-xs font-medium ${config.labelColor}`}
>
{count}
</ThemedText>
</View>
);
}
)} )}
</View>
{/* Alarm List */} {/* Alarm List */}
{alarms.length > 0 ? (
<FlatList <FlatList
data={displayAlarms} data={alarms}
renderItem={renderAlarmCard} renderItem={renderAlarmCard}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ItemSeparatorComponent={ItemSeparator}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
initialNumToRender={10} onEndReached={handleLoadMore}
maxToRenderPerBatch={10} onEndReachedThreshold={0.5}
windowSize={5} refreshing={refreshing}
onRefresh={handleRefresh}
ListFooterComponent={
isLoadingMore ? (
<View style={styles.footer}>
<ActivityIndicator size="small" color="#dc2626" />
<ThemedText style={styles.footerText}>Đang tải...</ThemedText>
</View>
) : null
}
/> />
) : (
<View style={styles.emptyContainer}>
<Ionicons name="shield-checkmark" size={48} color="#16a34a" />
<ThemedText style={styles.emptyText}>
Không cảnh báo nào
</ThemedText>
</View>
)}
</ThemedView> </ThemedView>
</SafeAreaView> </SafeAreaView>
); );
} };
export default WarningScreen;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
@@ -311,13 +260,60 @@ const styles = StyleSheet.create({
justifyContent: "space-between", justifyContent: "space-between",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 16, paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 8,
backgroundColor: "#dc2626",
alignItems: "center",
justifyContent: "center",
}, },
titleText: { titleText: {
fontSize: 26, fontSize: 24,
fontWeight: "700", fontWeight: "700",
}, },
badgeContainer: {
// backgroundColor: "#dc2626",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
badgeText: {
color: "#fff",
fontSize: 14,
fontWeight: "600",
},
listContent: { listContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 20, paddingVertical: 16,
},
footer: {
paddingVertical: 16,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 8,
},
footerText: {
fontSize: 14,
fontWeight: "500",
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
emptyText: {
fontSize: 16,
fontWeight: "500",
}, },
}); });

View File

@@ -2,14 +2,14 @@ import {
DarkTheme, DarkTheme,
DefaultTheme, DefaultTheme,
ThemeProvider, ThemeProvider,
Theme,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar"; 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 "react-native-reanimated";
// import Toast from "react-native-toast-message";
// import { toastConfig } from "@/config";
import { toastConfig } from "@/config"; import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth"; import { setRouterInstance } from "@/config/auth";
import "@/global.css"; import "@/global.css";
@@ -18,21 +18,49 @@ import {
ThemeProvider as AppThemeProvider, ThemeProvider as AppThemeProvider,
useThemeContext, useThemeContext,
} from "@/hooks/use-theme-context"; } from "@/hooks/use-theme-context";
import { Colors } from "@/constants/theme";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import "../global.css"; import "../global.css";
function AppContent() { function AppContent() {
const router = useRouter(); const router = useRouter();
const { colorScheme } = useThemeContext(); const { colorScheme, colors } = useThemeContext();
console.log("Color Scheme: ", colorScheme);
useEffect(() => { useEffect(() => {
setRouterInstance(router); setRouterInstance(router);
}, [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 ( return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> // Wrap entire app with View that has themed background
// This prevents any white flash during screen transitions
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeProvider value={navigationTheme}>
<Stack <Stack
screenOptions={{ headerShown: false }} screenOptions={{
headerShown: false,
// Set content background to match theme
contentStyle: {
backgroundColor: colors.background,
},
// Animation settings for smoother transitions
animation: "slide_from_right",
}}
initialRouteName="auth/login" initialRouteName="auth/login"
> >
<Stack.Screen <Stack.Screen
@@ -51,17 +79,40 @@ function AppContent() {
}} }}
/> />
<Stack.Screen
name="trip-detail"
options={{
title: "Trip Detail",
headerShown: false,
}}
/>
<Stack.Screen
name="trip-crew"
options={{
title: "Trip Crew",
headerShown: false,
}}
/>
<Stack.Screen <Stack.Screen
name="modal" name="modal"
options={{ presentation: "formSheet", title: "Modal" }} options={{ presentation: "formSheet", title: "Modal" }}
/> />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} /> <Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider> </ThemeProvider>
</View>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default function RootLayout() { export default function RootLayout() {
return ( return (
<I18nProvider> <I18nProvider>

358
app/trip-crew.tsx Normal file
View File

@@ -0,0 +1,358 @@
import React, { useEffect, useState, useCallback } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
ActivityIndicator,
Alert,
ScrollView,
} 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, deleteTripCrew } 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, tripStatus } = useLocalSearchParams<{
tripId: string;
tripName?: string;
tripStatus?: string;
}>();
// State
const [loading, setLoading] = useState(false);
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
const [error, setError] = useState<string | null>(null);
// Add/Edit modal state
const [showAddEditModal, setShowAddEditModal] = useState(false);
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(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 () => {
try {
// Call delete API
await deleteTripCrew(tripId, crew.PersonalID || "");
// Reload list
await fetchCrewData();
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
} catch (error) {
console.error("Error deleting crew:", error);
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
}
},
},
]
);
};
// Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách
const handleSaveCrew = async () => {
await fetchCrewData();
};
const themedStyles = {
container: { backgroundColor: colors.background },
header: {
backgroundColor: colors.card,
borderBottomColor: colors.separator,
},
title: { color: colors.text },
subtitle: { color: colors.textSecondary },
};
return (
<SafeAreaView
style={[styles.container, themedStyles.container]}
edges={["top"]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.crew.title")}
</Text>
{tripName && (
<Text
style={[styles.subtitle, themedStyles.subtitle]}
numberOfLines={1}
>
{tripName}
</Text>
)}
</View>
{tripStatus === "0" && (
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
<Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity>
)}
</View>
{/* Content */}
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.crew.loading")}
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons
name="alert-circle-outline"
size={48}
color={colors.error || "#FF3B30"}
/>
<Text
style={[styles.errorText, { color: colors.error || "#FF3B30" }]}
>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: colors.primary }]}
onPress={fetchCrewData}
>
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
</TouchableOpacity>
</View>
) : (
<CrewList
crews={crews}
onEdit={handleEditCrew}
onDelete={handleDeleteCrew}
/>
)}
</ScrollView>
{/* Footer - Crew count */}
<View
style={[
styles.footer,
{ backgroundColor: colors.card, borderTopColor: colors.separator },
]}
>
<View style={styles.countContainer}>
<Ionicons name="people-outline" size={20} color={colors.primary} />
<Text style={[styles.countText, { color: colors.text }]}>
{t("diary.crew.totalMembers", { count: crews.length })}
</Text>
</View>
</View>
{/* Add/Edit Crew Modal */}
<AddEditCrewModal
visible={showAddEditModal}
onClose={() => {
setShowAddEditModal(false);
setEditingCrew(null);
}}
onSave={handleSaveCrew}
mode={editingCrew ? "edit" : "add"}
tripId={tripId}
existingCrewIds={crews.map((c) => c.PersonalID || "")}
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 lấy từ trip (ghi chú chuyến đi), fallback về note từ Person
note: editingCrew.note || "",
}
: undefined
}
tripStatus={tripStatus ? Number(tripStatus) : undefined}
/>
</SafeAreaView>
);
}
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,
},
scrollContent: {
padding: 16,
paddingBottom: 20,
},
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",
}),
},
});

456
app/trip-detail.tsx Normal file
View File

@@ -0,0 +1,456 @@
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";
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";
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 } = useLocalSearchParams<{
tripId: string;
}>();
const [loading, setLoading] = useState(true);
const [trip, setTrip] = useState<Model.Trip | null>(null);
const [alerts, setAlerts] = useState<Model.Alarm[]>([]);
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
// Fetch trip data từ API
useEffect(() => {
const fetchTripData = async () => {
if (!tripId) return;
setLoading(true);
try {
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);
}
};
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(
() => 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;
}) => (
<View style={styles.emptySection}>
<Ionicons name={icon as any} size={40} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{message}
</Text>
</View>
);
// Render loading state
if (loading) {
return (
<SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("common.loading")}
</Text>
</View>
</SafeAreaView>
);
}
// Render error state
if (!trip) {
return (
<SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
<Header
title={t("diary.tripDetail.title")}
onBack={() => router.back()}
colors={colors}
/>
<View style={styles.errorContainer}>
<Ionicons
name="alert-circle-outline"
size={48}
color={colors.error || "#FF3B30"}
/>
<Text style={[styles.errorText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.notFound")}
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
{/* Header with status badge */}
<View
style={[
styles.header,
{ backgroundColor: colors.card, borderBottomColor: colors.separator },
]}
>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text
style={[styles.title, { color: colors.text }]}
numberOfLines={1}
>
{trip.name || t("diary.tripDetail.title")}
</Text>
<View
style={[
styles.statusBadge,
{ backgroundColor: statusConfig.bgColor },
]}
>
<Ionicons
name={statusConfig.icon as any}
size={12}
color={statusConfig.textColor}
/>
<Text
style={[styles.statusText, { color: statusConfig.textColor }]}
>
{statusConfig.label}
</Text>
</View>
</View>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* Basic Info */}
<BasicInfoSection trip={trip} />
{/* Alerts */}
<AlertsSection alerts={alerts} />
{/* Trip Costs */}
<SectionCard
title={t("diary.tripDetail.costs")}
icon="wallet-outline"
count={tripCosts.length}
collapsible
defaultExpanded
>
{tripCosts.length > 0 ? (
<View style={styles.sectionInnerContent}>
<MaterialCostList
items={tripCosts}
onChange={() => {}}
disabled
hideTitle
/>
</View>
) : (
<EmptySection
icon="receipt-outline"
message={t("diary.tripDetail.noCosts")}
/>
)}
</SectionCard>
{/* Fishing Gears */}
<SectionCard
title={t("diary.tripDetail.gears")}
icon="construct-outline"
count={fishingGears.length}
collapsible
defaultExpanded
>
{fishingGears.length > 0 ? (
<View style={styles.sectionInnerContent}>
<FishingGearList
items={fishingGears}
onChange={() => {}}
disabled
hideTitle
/>
</View>
) : (
<EmptySection
icon="build-outline"
message={t("diary.tripDetail.noGears")}
/>
)}
</SectionCard>
{/* Crew List */}
<SectionCard
title={t("diary.tripDetail.crew")}
icon="people-outline"
count={crews.length}
collapsible
defaultExpanded
>
{crews.length > 0 ? (
<CrewList crews={crews} />
) : (
<EmptySection
icon="person-add-outline"
message={t("diary.tripDetail.noCrew")}
/>
)}
</SectionCard>
{/* Fishing Logs */}
<FishingLogsSection fishingLogs={trip.fishing_logs} />
</ScrollView>
</SafeAreaView>
);
}
// Header component for reuse
function Header({
title,
onBack,
colors,
}: {
title: string;
onBack: () => void;
colors: any;
}) {
return (
<View
style={[
styles.header,
{ backgroundColor: colors.card, borderBottomColor: colors.separator },
]}
>
<TouchableOpacity onPress={onBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
</View>
<View style={styles.placeholder} />
</View>
);
}
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: 5,
},
emptySection: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 24,
gap: 8,
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,48 +0,0 @@
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
interface ButtonCancelTripProps {
title?: string;
onPress?: () => void;
}
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
title,
onPress,
}) => {
const { t } = useI18n();
const displayTitle = title || t("trip.buttonCancelTrip.title");
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.8}
>
<Text style={styles.text}>{displayTitle}</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 20,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2, // cho Android
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
textAlign: "center",
},
});
export default ButtonCancelTrip;

View File

@@ -1,213 +0,0 @@
import { queryGpsData } from "@/controller/DeviceController";
import {
queryStartNewHaul,
queryUpdateTripState,
} from "@/controller/TripController";
import { useI18n } from "@/hooks/use-i18n";
import {
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@/services/toast_service";
import { useTrip } from "@/state/use-trip";
import { AntDesign } from "@expo/vector-icons";
import React, { useEffect, useState } from "react";
import { Alert, StyleSheet, View } from "react-native";
import IconButton from "./IconButton";
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
interface StartButtonProps {
gpsData?: Model.GPSResponse;
onPress?: () => void;
}
interface a {
fishingLogs?: Model.FishingLogInfo[] | null;
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
isEditing?: boolean;
}
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
gpsData,
onPress,
}) => {
const [isStarted, setIsStarted] = useState(false);
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
const { t } = useI18n();
const { trip, getTrip } = useTrip();
useEffect(() => {
getTrip();
}, []);
const checkHaulFinished = () => {
return trip?.fishing_logs?.some((h) => h.status === 0);
};
const handlePress = () => {
if (isStarted) {
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
{
text: t("trip.cancelButton"),
style: "cancel",
},
{
text: t("trip.endButton"),
onPress: () => {
setIsStarted(false);
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
},
},
]);
} else {
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
{
text: t("trip.cancelButton"),
style: "cancel",
},
{
text: t("trip.startButton"),
onPress: () => {
setIsStarted(true);
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
},
},
]);
}
if (onPress) {
onPress();
}
};
const handleStartTrip = async (state: number, note?: string) => {
if (trip?.trip_status !== 2) {
showWarningToast(t("trip.alreadyStarted"));
return;
}
try {
const resp = await queryUpdateTripState({
status: state,
note: note || "",
});
if (resp.status === 200) {
showSuccessToast(t("trip.startTripSuccess"));
await getTrip();
}
} catch (error) {
console.error("Error stating trip :", error);
showErrorToast("");
}
};
const createNewHaul = async () => {
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
showWarningToast(t("trip.finishCurrentHaul"));
return;
}
if (!gpsData) {
const response = await queryGpsData();
gpsData = response.data;
}
try {
const body: Model.NewFishingLogRequest = {
trip_id: trip?.id || "",
start_at: new Date(),
start_lat: gpsData.lat,
start_lon: gpsData.lon,
weather_description: t("trip.weatherDescription"),
};
const resp = await queryStartNewHaul(body);
if (resp.status === 200) {
showSuccessToast(t("trip.startHaulSuccess"));
await getTrip();
} else {
showErrorToast(t("trip.createHaulFailed"));
}
} catch (error) {
console.log(error);
// showErrorToast(t("trip.createHaulFailed"));
}
};
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
return null;
}
return (
<View>
{trip?.trip_status === 2 ? (
<IconButton
icon={<AntDesign name="plus" />}
type="primary"
style={{ backgroundColor: "green", borderRadius: 10 }}
onPress={async () => handleStartTrip(3)}
>
{t("trip.startTrip")}
</IconButton>
) : checkHaulFinished() ? (
<IconButton
icon={<AntDesign name="plus" color={"white"} />}
type="primary"
style={{ borderRadius: 10 }}
onPress={() => setIsFinishHaulModalOpen(true)}
>
{t("trip.endHaul")}
</IconButton>
) : (
<IconButton
icon={<AntDesign name="plus" color={"white"} />}
type="primary"
style={{ borderRadius: 10 }}
onPress={async () => {
createNewHaul();
}}
>
{t("trip.startHaul")}
</IconButton>
)}
<CreateOrUpdateHaulModal
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
fishingLogIndex={trip?.fishing_logs?.length!}
isVisible={isFinishHaulModalOpen}
onClose={function (): void {
setIsFinishHaulModalOpen(false);
}}
/>
</View>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#4ecdc4", // màu ngọc lam
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 3,
shadowOffset: { width: 0, height: 2 },
elevation: 3, // hiệu ứng nổi trên Android
},
buttonActive: {
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
},
content: {
flexDirection: "row",
alignItems: "center",
},
icon: {
marginRight: 6,
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});
export default ButtonCreateNewHaulOrTrip;

View File

@@ -1,45 +0,0 @@
import { useI18n } from "@/hooks/use-i18n";
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
interface ButtonEndTripProps {
title?: string;
onPress?: () => void;
}
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
const { t } = useI18n();
const displayTitle = title || t("trip.buttonEndTrip.title");
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.85}
>
<Text style={styles.text}>{displayTitle}</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#ed9434", // màu cam sáng
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 28,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 2, // hiệu ứng nổi trên Android
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
textAlign: "center",
},
});
export default ButtonEndTrip;

View File

@@ -1,3 +1,4 @@
import { useAppTheme } from "@/hooks/use-app-theme";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@@ -36,6 +37,7 @@ export default function DraggablePanel({
}: DraggablePanelProps) { }: DraggablePanelProps) {
const { height: screenHeight } = useWindowDimensions(); const { height: screenHeight } = useWindowDimensions();
const bottomOffset = useBottomTabBarHeight(); const bottomOffset = useBottomTabBarHeight();
const { colors } = useAppTheme();
const minHeight = screenHeight * minHeightPct; const minHeight = screenHeight * minHeightPct;
const maxHeight = screenHeight * maxHeightPct; const maxHeight = screenHeight * maxHeightPct;
@@ -141,7 +143,12 @@ export default function DraggablePanel({
return ( return (
<Animated.View style={[styles.panelContainer, animatedStyle]}> <Animated.View style={[styles.panelContainer, animatedStyle]}>
<View style={[styles.panel, { height: maxHeight }]}> <View
style={[
styles.panel,
{ height: maxHeight, backgroundColor: colors.surface },
]}
>
{/* Header với drag handle và nút toggle */} {/* Header với drag handle và nút toggle */}
<GestureDetector gesture={panGesture}> <GestureDetector gesture={panGesture}>
<Pressable onPress={togglePanel} style={styles.header}> <Pressable onPress={togglePanel} style={styles.header}>
@@ -151,7 +158,7 @@ export default function DraggablePanel({
style={styles.toggleButton} style={styles.toggleButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
> >
<Ionicons name={iconName} size={24} color="#666" /> <Ionicons name={iconName} size={24} color={colors.icon} />
</TouchableOpacity> </TouchableOpacity>
</Pressable> </Pressable>
</GestureDetector> </GestureDetector>

View File

@@ -1,6 +1,6 @@
import { Colors } from "@/config"; import { Colors } from "@/config";
import { queryShipGroups } from "@/controller/DeviceController";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context"; import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useShipGroups } from "@/state/use-ship-groups";
import { useShipTypes } from "@/state/use-ship-types"; import { useShipTypes } from "@/state/use-ship-types";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@@ -44,8 +44,8 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
[colors, colorScheme] [colors, colorScheme]
); );
const { shipTypes, getShipTypes } = useShipTypes(); const { shipTypes, getShipTypes } = useShipTypes();
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
const [slideAnim] = useState(new Animated.Value(0)); const [slideAnim] = useState(new Animated.Value(0));
const { shipGroups, getShipGroups } = useShipGroups();
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({ const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
defaultValues: { defaultValues: {
@@ -70,8 +70,10 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
}, [shipTypes]); }, [shipTypes]);
useEffect(() => { useEffect(() => {
if (shipGroups === null) {
getShipGroups(); getShipGroups();
}, []); }
}, [props.isOpen]);
useEffect(() => { useEffect(() => {
if (props.isOpen) { if (props.isOpen) {
@@ -107,17 +109,6 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
} }
}, [props.initialValues]); }, [props.initialValues]);
const getShipGroups = async () => {
try {
const response = await queryShipGroups();
if (response && response.data) {
setGroupShips(response.data);
}
} catch (error) {
console.error("Error fetching ship groups:", error);
}
};
const alarmListLabel = [ const alarmListLabel = [
{ {
label: "Tiếp cận vùng hạn chế", label: "Tiếp cận vùng hạn chế",
@@ -366,10 +357,12 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
name="ship_group_id" name="ship_group_id"
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<Select <Select
options={groupShips.map((group) => ({ options={
shipGroups?.map((group) => ({
label: group.name || "", label: group.name || "",
value: group.id || "", value: group.id || "",
}))} })) || []
}
placeholder="Chọn đội tàu" placeholder="Chọn đội tàu"
mode="multiple" mode="multiple"
value={value} value={value}

View File

@@ -0,0 +1,464 @@
import {
queryConfirmAlarm,
queryrUnconfirmAlarm,
} from "@/controller/AlarmController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import React, { useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
Modal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
interface AlarmCardProps {
alarm: Model.Alarm;
onReload?: (onReload: boolean) => void;
}
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
const { colors } = useThemeContext();
const [showModal, setShowModal] = useState(false);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit = useMemo(
() => note.trim().length > 0 || alarm.confirmed,
[note, alarm.confirmed]
);
// Determine level and colors based on alarm level
// 0: Thông tin (Xanh lá), 1: Cảnh báo (Vàng), 2: Nguy hiểm (Đỏ), 3: Khẩn cấp SOS (Tím)
const getAlarmConfig = (level?: number) => {
switch (level) {
case 3:
// SOS - Khẩn cấp (Purple)
return {
level: 3,
icon: "alert" as const,
bgColor: colors.card,
borderColor: "#9333EA", // Purple
iconColor: "#9333EA",
statusBg: "#dcfce7",
statusText: "#166534",
};
case 2:
// Nguy hiểm - Đỏ
return {
level: 2,
icon: "warning" as const,
bgColor: colors.card,
borderColor: colors.error,
iconColor: colors.error,
statusBg: "#dcfce7",
statusText: "#166534",
};
case 1:
// Cảnh báo - Vàng
return {
level: 1,
icon: "alert-circle" as const,
bgColor: colors.card,
borderColor: colors.warning,
iconColor: colors.warning,
statusBg: "#fef08a",
statusText: "#713f12",
};
case 0:
default:
// Thông tin - Xanh lá
return {
level: 0,
icon: "information-circle" as const,
bgColor: colors.card,
borderColor: colors.success,
iconColor: colors.success,
statusBg: "#dcfce7",
statusText: "#166534",
};
}
};
const config = getAlarmConfig(alarm.level);
const formatDate = (timestamp?: number) => {
if (!timestamp) return "N/A";
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
};
const ensurePayload = () => {
if (!alarm.id || !alarm.thing_id || !alarm.time) {
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
return false;
}
return true;
};
const submitConfirm = async (action: "confirm" | "unconfirm") => {
if (!ensurePayload()) return;
if (action === "confirm" && note.trim().length === 0) {
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
return;
}
try {
setSubmitting(true);
if (action === "confirm") {
await queryConfirmAlarm({
id: alarm.id!,
thing_id: alarm.thing_id!,
time: alarm.time!,
description: note.trim(),
});
} else {
await queryrUnconfirmAlarm({
id: alarm.id!,
thing_id: alarm.thing_id!,
time: alarm.time!,
});
}
onReload?.(true);
} catch (error: any) {
console.error("Cannot confirm/unconfirm alarm: ", error);
const status = error?.response?.status ?? error?.status;
// If server returns 404, ignore silently
if (status !== 404) {
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
}
} finally {
setSubmitting(false);
setShowModal(false);
setNote("");
}
};
const handlePress = (alarm: Model.Alarm) => {
if (alarm.confirmed) {
Alert.alert(
"Thông báo",
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
[
{ text: "Hủy", style: "cancel" },
{
text: "Ngừng xác nhận",
style: "destructive",
onPress: () => submitConfirm("unconfirm"),
},
]
);
} else {
setShowModal(true);
}
};
return (
<View
style={[
styles.card,
{
backgroundColor: config.bgColor,
borderLeftColor: config.borderColor,
borderLeftWidth: 5,
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
},
]}
>
<View style={styles.container}>
{/* Left Side - Icon and Content */}
<View style={styles.content}>
{/* Icon */}
<View
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
>
<Ionicons name={config.icon} size={24} color={config.iconColor} />
</View>
{/* Title and Info */}
<View style={styles.textContainer}>
{/* Name */}
<View style={styles.titleRow}>
<Text
style={[styles.title, { color: colors.text }]}
numberOfLines={2}
>
{alarm.name || alarm.thing_name || "Unknown"}
</Text>
</View>
{/* Location (thing_name) and Time */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text
style={[styles.infoLabel, { color: colors.textSecondary }]}
>
Trạm
</Text>
<Text
style={[styles.infoValue, { color: colors.text }]}
numberOfLines={1}
>
{alarm.thing_name || "Unknown"}
</Text>
</View>
<View style={styles.infoItem}>
<Text
style={[styles.infoLabel, { color: colors.textSecondary }]}
>
Thời gian
</Text>
<Text
style={[styles.infoValue, { color: colors.text }]}
numberOfLines={1}
>
{formatDate(alarm.time)}
</Text>
</View>
</View>
{/* Status Badge */}
<TouchableOpacity
style={styles.statusContainer}
onPress={() => handlePress(alarm)}
activeOpacity={0.7}
>
<View
style={[
styles.statusBadge,
{
backgroundColor: alarm.confirmed
? "#8FD14F"
: colors.surfaceSecondary,
},
]}
>
<Text
style={[
styles.statusText,
{ color: alarm.confirmed ? "#166534" : colors.text },
]}
>
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{alarm.confirmed && (
<View style={styles.rightIcon}>
<Ionicons
name="checkmark-done"
size={20}
color={alarm.confirmed ? "#78C841" : config.iconColor}
/>
</View>
)}
</View>
<Modal
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContent,
{ backgroundColor: colors.background },
]}
>
<Text style={[styles.modalTitle, { color: colors.text }]}>
Nhập ghi chú xác nhận
</Text>
<TextInput
style={[
styles.input,
{ color: colors.text, borderColor: colors.border },
]}
placeholder="Nhập ghi chú"
placeholderTextColor={colors.textSecondary}
multiline
value={note}
onChangeText={setNote}
editable={!submitting}
/>
<View style={styles.modalActions}>
<TouchableOpacity
style={[
styles.modalButton,
styles.cancelButton,
{ backgroundColor: colors.surfaceSecondary },
]}
onPress={() => {
setShowModal(false);
setNote("");
}}
disabled={submitting}
>
<Text style={[styles.cancelText, { color: colors.text }]}>
Hủy
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.modalButton,
styles.confirmButton,
!canSubmit && styles.disabledButton,
]}
onPress={() => submitConfirm("confirm")}
disabled={submitting || !canSubmit}
>
{submitting ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.confirmText}>Xác nhận</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 12,
// borderWidth: 1,
paddingVertical: 16,
paddingHorizontal: 12,
marginBottom: 12,
},
container: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
},
content: {
flex: 1,
flexDirection: "row",
alignItems: "flex-start",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "flex-start",
justifyContent: "flex-start",
// marginRight: 5,
},
textContainer: {
flex: 1,
},
titleRow: {
marginBottom: 8,
},
code: {
fontSize: 12,
fontWeight: "600",
marginBottom: 4,
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: 8,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 12,
gap: 16,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 12,
marginBottom: 4,
},
infoValue: {
fontSize: 14,
fontWeight: "500",
},
statusContainer: {
marginTop: 8,
},
statusBadge: {
alignSelf: "flex-start",
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 20,
borderWidth: 0.2,
},
statusText: {
fontSize: 12,
fontWeight: "600",
},
rightIcon: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
marginLeft: 12,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
paddingHorizontal: 16,
},
modalContent: {
borderRadius: 12,
padding: 16,
gap: 12,
},
modalTitle: {
fontSize: 16,
fontWeight: "700",
},
input: {
minHeight: 80,
borderRadius: 8,
borderWidth: 1,
borderColor: "#e5e7eb",
padding: 12,
textAlignVertical: "top",
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
},
modalButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
},
cancelButton: {
backgroundColor: "#e5e7eb",
},
confirmButton: {
backgroundColor: "#dc2626",
},
disabledButton: {
opacity: 0.6,
},
cancelText: {
color: "#111827",
fontWeight: "600",
},
confirmText: {
color: "#fff",
fontWeight: "700",
},
});
export default AlarmCard;

View File

@@ -0,0 +1,305 @@
import Select, { SelectOption } from "@/components/Select";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
interface AlarmSearchFormProps {
initialValue?: {
name?: string;
level?: number;
confirmed?: boolean;
};
onSubmit: (payload: {
name?: string;
level?: number;
confirmed?: boolean;
}) => void;
onReset?: () => void;
}
interface FormData {
name: string;
level: number;
confirmed: string; // Using string for Select component compatibility
}
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
initialValue,
onSubmit,
onReset,
}) => {
const { colors } = useThemeContext();
const levelOptions: SelectOption[] = [
{ label: "Tất cả", value: 0 },
{ label: "Cảnh báo", value: 1 },
{ label: "Nguy hiểm", value: 2 },
];
const confirmedOptions: SelectOption[] = [
{ label: "Tất cả", value: "" },
{ label: "Đã xác nhận", value: "true" },
{ label: "Chưa xác nhận", value: "false" },
];
const { control, handleSubmit, reset } = useForm<FormData>({
defaultValues: {
name: initialValue?.name || "",
level: initialValue?.level || 0,
confirmed:
initialValue?.confirmed !== undefined
? initialValue.confirmed.toString()
: "",
},
});
useEffect(() => {
if (initialValue) {
reset({
name: initialValue.name || "",
level: initialValue.level || 0,
confirmed:
initialValue.confirmed !== undefined
? initialValue.confirmed.toString()
: "",
});
}
}, [initialValue, reset]);
const onFormSubmit = (data: FormData) => {
const payload: {
name?: string;
level?: number;
confirmed?: boolean;
} = {
...(data.name && { name: data.name }),
...(data.level !== 0 && { level: data.level }),
...(data.confirmed !== "" && {
confirmed: data.confirmed === "true",
}),
};
onSubmit(payload);
};
const handleReset = () => {
reset({
name: "",
level: 0,
confirmed: undefined,
});
// Submit empty payload to reset filters
onSubmit({});
onReset?.();
};
return (
<ThemedView
style={[
styles.container,
{
backgroundColor: colors.background,
borderBottomColor: colors.border,
height: "auto",
},
]}
>
<View style={styles.content}>
{/* Search Input */}
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
<View
style={[styles.inputWrapper, { borderColor: colors.border }]}
>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Tìm theo tên cảnh báo"
placeholderTextColor={colors.textSecondary}
value={value}
onChangeText={onChange}
onBlur={onBlur}
/>
{value ? (
<TouchableOpacity
onPress={() => onChange("")}
style={styles.clearButton}
>
<Ionicons
name="close-circle"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
) : null}
</View>
</View>
)}
/>
{/* Level and Confirmed Selects */}
<View style={styles.row}>
<View style={styles.halfWidth}>
<Controller
control={control}
name="level"
render={({ field: { onChange, value } }) => (
<View style={styles.selectContainer}>
<ThemedText style={styles.label}>Mức đ</ThemedText>
<Select
placeholder="Chọn mức độ"
value={value}
onChange={onChange}
options={levelOptions}
size="middle"
/>
</View>
)}
/>
</View>
<View style={styles.halfWidth}>
<Controller
control={control}
name="confirmed"
render={({ field: { onChange, value } }) => (
<View style={styles.selectContainer}>
<ThemedText style={styles.label}>Trạng thái</ThemedText>
<Select
placeholder="Chọn trạng thái"
value={value}
onChange={onChange}
options={confirmedOptions}
size="middle"
/>
</View>
)}
/>
</View>
</View>
{/* Action Buttons */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={[
styles.button,
styles.secondaryButton,
{
backgroundColor: colors.backgroundSecondary,
borderColor: colors.border,
},
]}
onPress={handleReset}
>
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
Đt lại
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onFormSubmit)}
>
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
Tìm kiếm
</ThemedText>
</TouchableOpacity>
</View>
</View>
</ThemedView>
);
};
const styles = StyleSheet.create({
container: {
borderBottomWidth: 1,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
zIndex: 100,
},
content: {
padding: 16,
overflow: "visible",
},
inputContainer: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "500",
marginBottom: 6,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
},
input: {
flex: 1,
height: 40,
fontSize: 16,
},
clearButton: {
marginLeft: 8,
padding: 4,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 16,
zIndex: 10,
},
halfWidth: {
width: "48%",
zIndex: 5000,
},
selectContainer: {
// flex: 1, // Remove this to prevent taking full width
zIndex: 5000,
},
buttonRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: 12,
marginTop: 16,
},
button: {
flex: 1,
height: 40,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
secondaryButton: {
borderWidth: 1,
},
primaryButton: {
// backgroundColor is set dynamically
},
buttonText: {
fontSize: 16,
fontWeight: "600",
},
});
export default AlarmSearchForm;

View File

@@ -1,197 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
export type AlarmStatus = "confirmed" | "pending";
export interface AlarmListItem {
id: string;
code: string;
title: string;
station: string;
timestamp: number;
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
status: AlarmStatus;
}
type AlarmProp = {
alarmsData: AlarmListItem[];
onPress?: (alarm: AlarmListItem) => void;
};
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
return (
<FlatList
data={alarmsData}
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
ItemSeparatorComponent={() => <View className="h-3" />}
renderItem={({ item }) => (
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
)}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
/>
);
};
type AlarmCardProps = {
alarm: AlarmListItem;
onPress?: () => void;
};
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
alarm.level
);
const statusConfig = getStatusConfig(alarm.status);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
>
<View className="flex-row justify-between items-start">
{/* Left content */}
<View className="flex-row flex-1">
{/* Icon */}
<View
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
>
<Ionicons
name={getIconByLevel(alarm.level)}
size={20}
color={iconColor}
/>
</View>
{/* Info */}
<View className="flex-1">
{/* Code */}
<Text
className={`text-xs font-medium mb-1 ${getCodeTextColor(
alarm.level
)}`}
>
{alarm.code}
</Text>
{/* Title */}
<Text className="text-base font-semibold text-gray-800 mb-2">
{alarm.title}
</Text>
{/* Station and Time */}
<View className="flex-row">
<View className="mr-6">
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
<Text className="text-sm text-gray-600">{alarm.station}</Text>
</View>
<View>
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
<Text className="text-sm text-gray-600">
{formatTimestamp(alarm.timestamp)}
</Text>
</View>
</View>
{/* Status Badge */}
{/* <View className="mt-3">
<View
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
>
<Text
className={`text-xs font-medium ${statusConfig.textColor}`}
>
{statusConfig.label}
</Text>
</View>
</View> */}
</View>
</View>
{/* Checkmark for confirmed */}
{/* {alarm.status === "confirmed" && (
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
<Ionicons name="checkmark" size={16} color="white" />
</View>
)} */}
</View>
</TouchableOpacity>
);
};
const getColorsByLevel = (level: number) => {
switch (level) {
case 3: // Danger - Red
return {
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconColor: "#DC2626",
iconBgColor: "bg-red-100",
};
case 2: // Caution - Yellow/Orange
return {
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
iconColor: "#CA8A04",
iconBgColor: "bg-yellow-100",
};
case 1: // Info - Green
default:
return {
bgColor: "bg-green-50",
borderColor: "border-green-200",
iconColor: "#16A34A",
iconBgColor: "bg-green-100",
};
}
};
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
switch (level) {
case 3:
return "warning";
case 2:
return "alert-circle";
case 1:
default:
return "checkmark-circle";
}
};
const getCodeTextColor = (level: number) => {
switch (level) {
case 3:
return "text-red-600";
case 2:
return "text-yellow-600";
case 1:
default:
return "text-green-600";
}
};
const getStatusConfig = (status: AlarmStatus) => {
switch (status) {
case "confirmed":
return {
label: "Đã xác nhận",
bgColor: "bg-green-100",
textColor: "text-green-700",
};
case "pending":
default:
return {
label: "Chờ xác nhận",
bgColor: "bg-yellow-100",
textColor: "text-yellow-700",
};
}
};
const formatTimestamp = (timestamp: number) => {
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
};
export default AlarmList;

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useRef, useEffect } from "react";
import { import {
View, View,
Text, Text,
@@ -7,6 +7,8 @@ import {
StyleSheet, StyleSheet,
Platform, Platform,
ScrollView, ScrollView,
Animated,
Dimensions,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import StatusDropdown from "./StatusDropdown"; import StatusDropdown from "./StatusDropdown";
@@ -71,6 +73,47 @@ export default function FilterModal({
const [endDate, setEndDate] = useState<Date | null>(null); const [endDate, setEndDate] = useState<Date | null>(null);
const [selectedShip, setSelectedShip] = useState<ShipOption | null>(null); const [selectedShip, setSelectedShip] = useState<ShipOption | null>(null);
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(Dimensions.get('window').height)).current;
// Handle animation when modal visibility changes
useEffect(() => {
if (visible) {
// Open animation: fade overlay + slide content up
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
const handleClose = () => {
// 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(() => {
onClose();
});
};
const handleReset = () => { const handleReset = () => {
setStatus(null); setStatus(null);
setStartDate(null); setStartDate(null);
@@ -79,8 +122,22 @@ export default function FilterModal({
}; };
const handleApply = () => { const handleApply = () => {
// Close animation then apply
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get('window').height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
onApply({ status, startDate, endDate, selectedShip }); onApply({ status, startDate, endDate, selectedShip });
onClose(); onClose();
});
}; };
const hasFilters = const hasFilters =
@@ -128,23 +185,26 @@ export default function FilterModal({
return ( return (
<Modal <Modal
visible={visible} visible={visible}
animationType="fade" animationType="none"
transparent transparent
onRequestClose={onClose} onRequestClose={handleClose}
> >
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<TouchableOpacity <TouchableOpacity
style={styles.overlay} style={styles.overlayTouchable}
activeOpacity={1} activeOpacity={1}
onPress={onClose} onPress={handleClose}
> />
<TouchableOpacity <Animated.View
style={[styles.modalContainer, themedStyles.modalContainer]} style={[
activeOpacity={1} styles.modalContainer,
onPress={(e) => e.stopPropagation()} themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] }
]}
> >
{/* Header */} {/* Header */}
<View style={[styles.header, themedStyles.header]}> <View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}> <TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} /> <Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>{t("diary.filter")}</Text> <Text style={[styles.title, themedStyles.title]}>{t("diary.filter")}</Text>
@@ -218,8 +278,8 @@ export default function FilterModal({
<Text style={styles.applyButtonText}>{t("diary.apply")}</Text> <Text style={styles.applyButtonText}>{t("diary.apply")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</TouchableOpacity> </Animated.View>
</TouchableOpacity> </Animated.View>
</Modal> </Modal>
); );
} }
@@ -231,6 +291,13 @@ const styles = StyleSheet.create({
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end", justifyContent: "flex-end",
}, },
overlayTouchable: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
modalContainer: { modalContainer: {
borderTopLeftRadius: 24, borderTopLeftRadius: 24,
borderTopRightRadius: 24, borderTopRightRadius: 24,

View File

@@ -9,13 +9,13 @@ import {
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useTripStatusConfig } from "./types"; import { useTripStatusConfig } from "./types";
import { useThings } from "@/state/use-thing"; import { useThings } from "@/state/use-thing";
import { useShip } from "@/state/use-ship";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
interface TripCardProps { interface TripCardProps {
trip: Model.Trip; trip: Model.Trip;
onPress?: () => void;
onView?: () => void; onView?: () => void;
onEdit?: () => void; onEdit?: () => void;
onTeam?: () => void; onTeam?: () => void;
@@ -25,7 +25,6 @@ interface TripCardProps {
export default function TripCard({ export default function TripCard({
trip, trip,
onPress,
onView, onView,
onEdit, onEdit,
onTeam, onTeam,
@@ -35,6 +34,7 @@ export default function TripCard({
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const { things } = useThings(); const { things } = useThings();
const { ships } = useShip();
const TRIP_STATUS_CONFIG = useTripStatusConfig(); const TRIP_STATUS_CONFIG = useTripStatusConfig();
// Tìm thing có id trùng với vms_id của trip // 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 (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) // Lấy config status từ trip_status (number)
const statusKey = trip.trip_status as keyof typeof TRIP_STATUS_CONFIG; const statusKey = trip.trip_status as keyof typeof TRIP_STATUS_CONFIG;
const statusConfig = TRIP_STATUS_CONFIG[statusKey] || { const statusConfig = TRIP_STATUS_CONFIG[statusKey] || {
@@ -54,7 +57,7 @@ export default function TripCard({
// Determine which actions to show based on status // Determine which actions to show based on status
const showEdit = trip.trip_status === 0 || trip.trip_status === 1; const showEdit = trip.trip_status === 0 || trip.trip_status === 1;
const showSend = trip.trip_status === 0; const showSend = trip.trip_status === 0;
const showDelete = trip.trip_status === 1; const showDelete = trip.trip_status === 1 || trip.trip_status === 2;
const themedStyles = { const themedStyles = {
card: { card: {
@@ -80,7 +83,7 @@ export default function TripCard({
return ( return (
<View style={[styles.card, themedStyles.card]}> <View style={[styles.card, themedStyles.card]}>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}> <View>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
@@ -90,7 +93,9 @@ export default function TripCard({
color={statusConfig.textColor} color={statusConfig.textColor}
/> />
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, themedStyles.title]}>{trip.name}</Text> <Text style={[styles.title, themedStyles.title]}>
{trip.name}
</Text>
</View> </View>
</View> </View>
<View <View
@@ -117,14 +122,27 @@ export default function TripCard({
{/* Info Grid */} {/* Info Grid */}
<View style={styles.infoGrid}> <View style={styles.infoGrid}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipName")}</Text> <Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.shipName")}
</Text>
<Text style={[styles.value, themedStyles.value]}> <Text style={[styles.value, themedStyles.value]}>
{thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */} {thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */}
</Text> </Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.departure")}</Text> <Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.shipCode")}
</Text>
<Text style={[styles.value, themedStyles.value]}>
{shipOfTrip?.reg_number || "-"}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.departure")}
</Text>
<Text style={[styles.value, themedStyles.value]}> <Text style={[styles.value, themedStyles.value]}>
{trip.departure_time {trip.departure_time
? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm") ? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm")
@@ -133,7 +151,9 @@ export default function TripCard({
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.return")}</Text> <Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.return")}
</Text>
{/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */} {/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */}
<Text style={[styles.value, themedStyles.value]}> <Text style={[styles.value, themedStyles.value]}>
{trip.arrival_time {trip.arrival_time
@@ -142,7 +162,7 @@ export default function TripCard({
</Text> </Text>
</View> </View>
</View> </View>
</TouchableOpacity> </View>
{/* Action Buttons */} {/* Action Buttons */}
<View style={[styles.divider, themedStyles.divider]} /> <View style={[styles.divider, themedStyles.divider]} />
@@ -153,7 +173,9 @@ export default function TripCard({
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="eye-outline" size={20} color={colors.textSecondary} /> <Ionicons name="eye-outline" size={20} color={colors.textSecondary} />
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.view")}</Text> <Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.view")}
</Text>
</TouchableOpacity> </TouchableOpacity>
{showEdit && ( {showEdit && (
@@ -162,8 +184,14 @@ export default function TripCard({
onPress={onEdit} onPress={onEdit}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="create-outline" size={20} color={colors.textSecondary} /> <Ionicons
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.edit")}</Text> name="create-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.edit")}
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -172,8 +200,14 @@ export default function TripCard({
onPress={onTeam} onPress={onTeam}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="people-outline" size={20} color={colors.textSecondary} /> <Ionicons
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.team")}</Text> name="people-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.team")}
</Text>
</TouchableOpacity> </TouchableOpacity>
{showSend && ( {showSend && (
@@ -182,8 +216,14 @@ export default function TripCard({
onPress={onSend} onPress={onSend}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="send-outline" size={20} color={colors.textSecondary} /> <Ionicons
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.send")}</Text> name="send-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.send")}
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -194,7 +234,9 @@ export default function TripCard({
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="trash-outline" size={20} color={colors.error} /> <Ionicons name="trash-outline" size={20} color={colors.error} />
<Text style={[styles.actionText, styles.deleteText]}>{t("diary.tripCard.delete")}</Text> <Text style={[styles.actionText, styles.deleteText]}>
{t("diary.tripCard.delete")}
</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>

View File

@@ -0,0 +1,992 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
TextInput,
ScrollView,
ActivityIndicator,
Animated,
Dimensions,
Alert,
Image,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
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";
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<void>;
mode: "add" | "edit";
initialData?: Partial<CrewFormData>;
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; // 1 seconds debounce
export default function AddEditCrewModal({
visible,
onClose,
onSave,
mode,
initialData,
tripId,
existingCrewIds = [],
tripStatus,
}: 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<CrewFormData>({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [isWaitingDebounce, setIsWaitingDebounce] = useState(false);
const [searchStatus, setSearchStatus] = useState<
"idle" | "found" | "not_found" | "error"
>("idle");
const [foundPersonData, setFoundPersonData] =
useState<Model.TripCrewPerson | null>(null);
// State quản lý ảnh thuyền viên
const [imageUri, setImageUri] = useState<string | null>(null);
const [newImageUri, setNewImageUri] = useState<string | null>(null);
const [isLoadingImage, setIsLoadingImage] = useState(false);
// Debounce timer ref
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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 || "",
});
setSearchStatus("idle");
setFoundPersonData(null);
// Reset ảnh khi mở modal edit
setNewImageUri(null);
setImageUri(null);
} else if (visible && mode === "add") {
// Reset form for add mode
setFormData({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
setSearchStatus("idle");
setFoundPersonData(null);
// Reset ảnh
setNewImageUri(null);
setImageUri(null);
}
}, [visible, initialData, mode]);
// Load ảnh thuyền viên khi edit
useEffect(() => {
const loadCrewImage = async () => {
if (visible && mode === "edit" && initialData?.personalId) {
setIsLoadingImage(true);
try {
const response = await queryCrewImage(initialData.personalId);
if (response.data) {
const base64 = Buffer.from(response.data as ArrayBuffer).toString(
"base64"
);
setImageUri(`data:image/jpeg;base64,${base64}`);
}
} catch (error) {
// Không có ảnh hoặc lỗi - hiển thị placeholder
setImageUri(null);
} finally {
setIsLoadingImage(false);
}
}
};
loadCrewImage();
}, [visible, mode, initialData?.personalId]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Search crew by personal ID with debounce
const searchCrewByPersonalId = useCallback(async (personalId: string) => {
if (!personalId.trim() || personalId.length < 5) {
setSearchStatus("idle");
return;
}
setIsSearching(true);
setSearchStatus("idle");
try {
const response = await searchCrew(personalId.trim());
if (response.data) {
const person = response.data;
setFoundPersonData(person);
setSearchStatus("found");
// Auto-fill form with found data
setFormData((prev) => ({
...prev,
name: person.name || prev.name,
phone: person.phone || prev.phone,
email: person.email || prev.email,
address: person.address || prev.address,
}));
// 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);
}
} catch (error: any) {
// 404 or 401 means not found or unauthorized - treat as not found silently
const status = error?.response?.status;
if (status === 404 || status === 401) {
setSearchStatus("not_found");
} else {
// For other errors, just set to not_found without logging to console
setSearchStatus("not_found");
}
setFoundPersonData(null);
} finally {
setIsSearching(false);
}
}, []);
// Handle personal ID change with debounce
const handlePersonalIdChange = (value: string) => {
// If crew was previously found and user is changing personal ID, reset form
if (foundPersonData && value !== formData.personalId) {
setFormData({
personalId: value,
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
setFoundPersonData(null);
} else {
setFormData((prev) => ({ ...prev, personalId: value }));
}
setSearchStatus("idle");
setIsWaitingDebounce(false);
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Only search in add mode and when value is long enough
if (mode === "add" && value.trim().length >= 5) {
// Show waiting indicator
setIsWaitingDebounce(true);
// Set new timer
debounceTimerRef.current = setTimeout(() => {
setIsWaitingDebounce(false);
searchCrewByPersonalId(value);
}, DEBOUNCE_DELAY);
}
};
// Chọn ảnh từ thư viện
const pickImage = async () => {
// Xin quyền truy cập thư viện ảnh
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(t("common.error"), "Cần cấp quyền truy cập thư viện ảnh");
return;
}
// Mở image picker
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setNewImageUri(result.assets[0].uri);
}
};
// 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 = () => {
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
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 DỮ LIỆU ===
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;
}
// Kiểm tra thuyền viên đã có trong chuyến đi chưa (chỉ với mode add)
if (
mode === "add" &&
existingCrewIds.includes(formData.personalId.trim())
) {
Alert.alert(t("common.error"), t("diary.crew.form.crewAlreadyExists"));
return;
}
setIsSubmitting(true);
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
// 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: "", // Không gửi note - note là riêng cho từng chuyến đi
address: formData.address || "",
});
}
// Bước 2: Thêm thuyền viên vào chuyến đi với role
await newTripCrew({
trip_id: tripId,
personal_id: formData.personalId.trim(),
role: formData.role as "captain" | "crew",
});
} else if (mode === "edit" && tripId) {
// === 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)
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 (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
await onSave(formData);
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);
}
};
const updateField = (field: keyof CrewFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Render search status message
const renderSearchStatus = () => {
if (isSearching) {
return (
<View
style={[
styles.statusContainer,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
{t("diary.crew.form.searching")}
</Text>
</View>
);
}
if (searchStatus === "found") {
return (
<View
style={[
styles.statusContainer,
styles.statusSuccess,
{ backgroundColor: colors.success + "15" },
]}
>
<Ionicons name="checkmark-circle" size={18} color={colors.success} />
<Text style={[styles.statusText, { color: colors.success }]}>
{t("diary.crew.form.crewFound")}
</Text>
</View>
);
}
if (searchStatus === "not_found") {
return (
<View
style={[
styles.statusContainer,
styles.statusWarning,
{ backgroundColor: colors.warning + "15" },
]}
>
<Ionicons
name="information-circle"
size={18}
color={colors.warning}
/>
<Text style={[styles.statusText, { color: colors.warning }]}>
{t("diary.crew.form.crewNotFound")}
</Text>
</View>
);
}
return null;
};
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 (
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{mode === "add"
? t("diary.crew.form.addTitle")
: t("diary.crew.form.editTitle")}
</Text>
<View style={styles.headerPlaceholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 100 }}
automaticallyAdjustKeyboardInsets={true}
>
{/* Ảnh thuyền viên */}
<View style={styles.photoSection}>
<TouchableOpacity
style={[
styles.avatarContainer,
{ borderColor: colors.separator },
]}
onPress={pickImage}
activeOpacity={0.8}
>
{isLoadingImage ? (
<ActivityIndicator size="large" color={colors.primary} />
) : newImageUri || imageUri ? (
<Image
source={{ uri: newImageUri || imageUri || "" }}
style={styles.avatar}
/>
) : (
<View
style={[
styles.avatarPlaceholder,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<Ionicons
name="person"
size={50}
color={colors.textSecondary}
/>
</View>
)}
</TouchableOpacity>
<Text style={[styles.photoHint, { color: colors.textSecondary }]}>
Nhấn đ chọn nh
</Text>
</View>
{/* Personal ID */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.personalId")} *
</Text>
<View style={styles.inputWithIcon}>
<TextInput
style={[
styles.input,
styles.inputWithIconInput,
themedStyles.input,
]}
value={formData.personalId}
onChangeText={handlePersonalIdChange}
placeholder={t("diary.crew.form.personalIdPlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
editable={mode === "add"}
/>
{/* Show waiting icon during debounce, spinner during search */}
{(isWaitingDebounce || isSearching) && (
<View style={styles.inputIcon}>
{isSearching ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Ionicons
name="time-outline"
size={20}
color={colors.textSecondary}
/>
)}
</View>
)}
</View>
{mode === "add" && (
<Text style={[styles.hint, { color: colors.textSecondary }]}>
{isWaitingDebounce
? t("diary.crew.form.waitingSearch")
: t("diary.crew.form.searchHint")}
</Text>
)}
{renderSearchStatus()}
</View>
{/* Name */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.name")} *
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.name}
onChangeText={(v) => updateField("name", v)}
placeholder={t("diary.crew.form.namePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</View>
{/* Phone */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.phone")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.phone}
onChangeText={(v) => updateField("phone", v)}
placeholder={t("diary.crew.form.phonePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
keyboardType="phone-pad"
/>
</View>
{/* Role */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.role")}
</Text>
<View style={styles.roleContainer}>
{ROLES.map((role) => (
<TouchableOpacity
key={role}
style={[
styles.roleButton,
themedStyles.roleButton,
formData.role === role && themedStyles.roleButtonActive,
]}
onPress={() => updateField("role", role)}
>
<Text
style={[
styles.roleButtonText,
themedStyles.roleText,
formData.role === role && themedStyles.roleTextActive,
]}
>
{t(`diary.crew.roles.${role}`)}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Address */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.address")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.address}
onChangeText={(v) => updateField("address", v)}
placeholder={t("diary.crew.form.addressPlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</View>
{/* Note - Chỉ hiển thị khi edit, ẩn khi add */}
{mode === "edit" && (
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.note")}
</Text>
<TextInput
style={[styles.input, styles.textArea, themedStyles.input]}
value={formData.note}
onChangeText={(v) => updateField("note", v)}
placeholder={t("diary.crew.form.notePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
multiline
numberOfLines={3}
/>
</View>
)}
</ScrollView>
{/* Footer */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
<TouchableOpacity
style={[
styles.cancelButton,
{ backgroundColor: colors.backgroundSecondary },
]}
onPress={handleClose}
>
<Text
style={[
styles.cancelButtonText,
{ color: colors.textSecondary },
]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.saveButton,
{ backgroundColor: colors.primary },
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSave}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.saveButtonText}>{t("common.save")}</Text>
)}
</TouchableOpacity>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
},
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",
}),
},
inputWithIcon: {
position: "relative",
},
inputWithIconInput: {
paddingRight: 44,
},
inputIcon: {
position: "absolute",
right: 12,
top: 0,
bottom: 0,
justifyContent: "center",
},
hint: {
fontSize: 12,
marginTop: 4,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginTop: 8,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
},
statusSuccess: {},
statusWarning: {},
statusText: {
fontSize: 13,
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,
paddingHorizontal: 20,
paddingVertical: 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,
},
// Styles cho phần ảnh thuyền viên
photoSection: {
alignItems: "center",
marginBottom: 20,
},
avatarContainer: {
width: 100,
height: 100,
borderRadius: 50,
borderWidth: 2,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
position: "relative",
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
},
avatarPlaceholder: {
width: 100,
height: 100,
borderRadius: 50,
justifyContent: "center",
alignItems: "center",
},
cameraIconOverlay: {
position: "absolute",
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#FFFFFF",
},
photoHint: {
fontSize: 12,
marginTop: 8,
},
});

View File

@@ -0,0 +1,331 @@
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/CrewController";
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<string | null>(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 (
<View style={[styles.card, themedStyles.card]}>
{/* Left Image Section (1/3 width) */}
<View style={[styles.imageSection, themedStyles.imagePlaceholder]}>
{imageLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : imageUri && !imageError ? (
<Image
source={{ uri: imageUri }}
style={styles.crewImage}
resizeMode="cover"
/>
) : (
<View style={styles.imagePlaceholder}>
<Ionicons name="person" size={40} color={colors.textSecondary} />
</View>
)}
</View>
{/* Right Content Section (2/3 width) */}
<View style={styles.contentSection}>
{/* Name & Role */}
<View style={styles.header}>
<Text style={[styles.name, themedStyles.name]} numberOfLines={1}>
{person?.name || "-"}
</Text>
<View
style={[
styles.roleBadge,
{ backgroundColor: themedStyles.role.backgroundColor },
]}
>
<Text style={[styles.roleText, { color: themedStyles.role.color }]}>
{crew.role || t("diary.crew.member")}
</Text>
</View>
</View>
{/* Info Grid */}
<View style={styles.infoGrid}>
{/* Phone */}
<View style={styles.infoRow}>
<Ionicons
name="call-outline"
size={14}
color={themedStyles.iconColor}
/>
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
{person?.phone || "-"}
</Text>
</View>
{/* Personal ID */}
<View style={styles.infoRow}>
<Ionicons
name="card-outline"
size={14}
color={themedStyles.iconColor}
/>
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
{person?.personal_id || "-"}
</Text>
</View>
{/* Joined Date */}
<View style={styles.infoRow}>
<Ionicons
name="calendar-outline"
size={14}
color={themedStyles.iconColor}
/>
<Text style={[styles.value, themedStyles.value]}>{joinedDate}</Text>
</View>
{/* Left Date (only show if exists) */}
{leftDate && (
<View style={styles.infoRow}>
<Ionicons
name="exit-outline"
size={14}
color={themedStyles.iconColor}
/>
<Text style={[styles.value, themedStyles.value]}>{leftDate}</Text>
</View>
)}
</View>
{/* Action Buttons */}
{(onEdit || onDelete) && (
<View style={styles.actionRow}>
{onEdit && (
<TouchableOpacity
style={[
styles.actionButton,
{ backgroundColor: colors.primary + "15" },
]}
onPress={() => onEdit(crew)}
>
<Ionicons
name="pencil-outline"
size={14}
color={colors.primary}
/>
<Text style={[styles.actionText, { color: colors.primary }]}>
{t("common.edit")}
</Text>
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
style={[
styles.actionButton,
{ backgroundColor: (colors.error || "#FF3B30") + "15" },
]}
onPress={() => onDelete(crew)}
>
<Ionicons
name="trash-outline"
size={14}
color={colors.error || "#FF3B30"}
/>
<Text
style={[
styles.actionText,
{ color: colors.error || "#FF3B30" },
]}
>
{t("common.delete")}
</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: "row",
borderRadius: 12,
borderWidth: 1,
marginBottom: 12,
overflow: "hidden",
maxHeight: 160,
},
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",
}),
},
});

View File

@@ -0,0 +1,65 @@
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 }) => (
<CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} />
);
const keyExtractor = (item: Model.TripCrews, index: number) =>
`${item.PersonalID}-${index}`;
const renderEmpty = () => (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.crew.noCrewMembers")}
</Text>
</View>
);
return (
<FlatList
data={crews}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmpty}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
scrollEnabled={false}
nestedScrollEnabled
/>
);
}
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",
}),
},
});

View File

@@ -0,0 +1,437 @@
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<Model.TripCrews[]>([]);
const [error, setError] = useState<string | null>(null);
// Add/Edit modal state
const [showAddEditModal, setShowAddEditModal] = useState(false);
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(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 - API được gọi trong AddEditCrewModal, sau đó refresh danh sách
const handleSaveCrew = async () => {
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 (
<>
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity
onPress={handleClose}
style={styles.closeButton}
>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.crew.title")}
</Text>
{tripName && (
<Text
style={[styles.subtitle, themedStyles.subtitle]}
numberOfLines={1}
>
{tripName}
</Text>
)}
</View>
{/* Add Button */}
<TouchableOpacity
onPress={handleAddCrew}
style={styles.addButton}
>
<Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{/* Content */}
<View style={[styles.content, themedStyles.content]}>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text
style={[
styles.loadingText,
{ color: colors.textSecondary },
]}
>
{t("diary.crew.loading")}
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons
name="alert-circle-outline"
size={48}
color={colors.error || "#FF3B30"}
/>
<Text
style={[
styles.errorText,
{ color: colors.error || "#FF3B30" },
]}
>
{error}
</Text>
<TouchableOpacity
style={[
styles.retryButton,
{ backgroundColor: colors.primary },
]}
onPress={fetchCrewData}
>
<Text style={styles.retryButtonText}>
{t("common.retry")}
</Text>
</TouchableOpacity>
</View>
) : (
<CrewList
crews={crews}
onEdit={handleEditCrew}
onDelete={handleDeleteCrew}
/>
)}
</View>
{/* Footer - Crew count */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
<View style={styles.countContainer}>
<Ionicons
name="people-outline"
size={20}
color={colors.primary}
/>
<Text style={[styles.countText, { color: colors.text }]}>
{t("diary.crew.totalMembers", { count: crews.length })}
</Text>
</View>
</View>
</Animated.View>
</Animated.View>
</Modal>
{/* Add/Edit Crew Modal */}
<AddEditCrewModal
visible={showAddEditModal}
onClose={() => {
setShowAddEditModal(false);
setEditingCrew(null);
}}
onSave={handleSaveCrew}
mode={editingCrew ? "edit" : "add"}
tripId={tripId || undefined}
existingCrewIds={crews.map((c) => c.PersonalID)}
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",
}),
},
});

View File

@@ -0,0 +1,187 @@
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) => {
const isDark =
colors.background === "#1C1C1E" ||
colors.background === "#000000" ||
colors.background?.toLowerCase().includes("1c1c1e");
switch (level) {
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" };
}
};
const formatTime = (timestamp?: number) => {
if (!timestamp) return "--";
const date = new Date(timestamp * 1000);
return date.toLocaleString("vi-VN");
};
return (
<SectionCard
title={t("diary.tripDetail.alerts")}
icon="warning-outline"
count={alerts.length}
collapsible
//defaultExpanded={alerts.length > 0}
defaultExpanded
>
{alerts.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons
name="checkmark-circle-outline"
size={40}
color={colors.success || "#22C55E"}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noAlerts")}
</Text>
</View>
) : (
<View style={styles.list}>
{alerts.map((alert, index) => {
const levelColor = getAlertLevelColor(alert.level);
return (
<View
key={alert.id || index}
style={[styles.alertItem, { backgroundColor: levelColor.bg }]}
>
<View style={styles.alertHeader}>
<View style={styles.alertInfo}>
<Ionicons
name={alert.level === 1 ? "alert-circle" : "warning"}
size={18}
color={levelColor.text}
/>
<Text
style={[styles.alertName, { color: levelColor.text }]}
>
{alert.name || t("diary.tripDetail.unknownAlert")}
</Text>
</View>
{alert.confirmed && (
<View
style={[
styles.confirmedBadge,
{ backgroundColor: colors.success || "#22C55E" },
]}
>
<Text style={styles.confirmedText}>
{t("diary.tripDetail.confirmed")}
</Text>
</View>
)}
</View>
<Text style={[styles.alertTime, { color: levelColor.text }]}>
{formatTime(alert.time)}
</Text>
</View>
);
})}
</View>
)}
</SectionCard>
);
}
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",
}),
},
});

View File

@@ -0,0 +1,221 @@
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;
}
/**
* Displays basic trip information like ship, dates, ports
*/
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);
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.shipName"),
value: shipName,
},
{
icon: "barcode" as const,
label: t("diary.tripDetail.shipCode"),
value: shipCode,
},
{
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: getPortName(trip.departure_port_id),
},
{
icon: "flag" as const,
label: t("diary.tripDetail.arrivalPort"),
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(", ")
: "--",
},
];
return (
<View
style={[
styles.container,
{ backgroundColor: colors.card, borderColor: colors.separator },
]}
>
<View style={styles.header}>
<Ionicons
name="information-circle-outline"
size={20}
color={colors.primary}
/>
<Text style={[styles.title, { color: colors.text }]}>
{t("diary.tripDetail.basicInfo")}
</Text>
</View>
<View style={styles.content}>
{infoItems.map((item, index) => (
<View key={index} style={styles.infoItem}>
<View style={styles.infoLabel}>
<Ionicons
name={item.icon}
size={16}
color={colors.textSecondary}
/>
<Text
style={[styles.infoLabelText, { color: colors.textSecondary }]}
>
{item.label}
</Text>
</View>
<Text style={[styles.infoValue, { color: colors.text }]}>
{item.value}
</Text>
</View>
))}
</View>
</View>
);
}
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",
}),
},
});

View File

@@ -0,0 +1,452 @@
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.logStatusProcessing"),
color: "#FEF3C7",
textColor: "#92400E",
};
case 1:
return {
label: t("diary.tripDetail.logStatusSuccess"),
color: "#D1FAE5",
textColor: "#065F46",
};
case 2:
return {
label: t("diary.tripDetail.logStatusCancelled"),
color: "#FEE2E2",
textColor: "#B91C1C",
};
default:
return {
label: t("diary.tripDetail.logStatusUnknown"),
color: "#F3F4F6",
textColor: "#4B5563",
};
}
};
return (
<SectionCard
title={t("diary.tripDetail.fishingLogs")}
icon="boat-outline"
count={logs.length}
collapsible
defaultExpanded={true}
>
{logs.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons
name="fish-outline"
size={40}
color={colors.textSecondary}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noFishingLogs")}
</Text>
</View>
) : (
<View style={styles.list}>
{logs.map((log, index) => {
const status = getStatusLabel(log.status);
const catchCount = log.info?.length || 0;
return (
<View
key={log.fishing_log_id || index}
style={[styles.logItem, { borderColor: colors.separator }]}
>
{/* Header */}
<View style={styles.logHeader}>
<View style={styles.logIndex}>
<Text
style={[styles.logIndexText, { color: colors.primary }]}
>
#{index + 1}
</Text>
</View>
<View
style={[
styles.statusBadge,
{ backgroundColor: status.color },
]}
>
<Text
style={[styles.statusText, { color: status.textColor }]}
>
{status.label}
</Text>
</View>
</View>
{/* Time Info */}
<View style={styles.timeRow}>
<View style={styles.timeItem}>
<Ionicons
name="play-circle-outline"
size={16}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
{formatDateTime(log.start_at)}
</Text>
</View>
<View style={styles.timeItem}>
<Ionicons
name="stop-circle-outline"
size={16}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.endTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
{formatDateTime(log.end_at)}
</Text>
</View>
</View>
{/* Location Info */}
<View
style={[
styles.locationContainer,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<View style={styles.locationItem}>
<Ionicons
name="location"
size={14}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startLocation")}:
</Text>
<Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.start_lat, log.start_lon)}
</Text>
</View>
<View style={styles.locationItem}>
<Ionicons
name="location"
size={14}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.haulLocation")}:
</Text>
<Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.haul_lat, log.haul_lon)}
</Text>
</View>
</View>
{/* Catch Info */}
{catchCount > 0 && (
<View style={styles.catchContainer}>
<View style={styles.catchHeader}>
<Ionicons name="fish" size={16} color={colors.primary} />
<Text style={[styles.catchLabel, { color: colors.text }]}>
{t("diary.tripDetail.catchInfo")} ({catchCount}{" "}
{t("diary.tripDetail.species")})
</Text>
</View>
<View style={styles.catchList}>
{log.info?.slice(0, 3).map((fish, fishIndex) => (
<View key={fishIndex} style={styles.catchItem}>
<Text
style={[styles.fishName, { color: colors.text }]}
>
{fish.fish_name ||
t("diary.tripDetail.unknownFish")}
</Text>
<Text
style={[
styles.fishAmount,
{ color: colors.textSecondary },
]}
>
{fish.catch_number} {fish.catch_unit}
</Text>
</View>
))}
{catchCount > 3 && (
<Text
style={[styles.moreText, { color: colors.primary }]}
>
+{catchCount - 3} {t("diary.tripDetail.more")}
</Text>
)}
</View>
</View>
)}
{/* Weather */}
{log.weather_description && (
<View style={styles.weatherRow}>
<Ionicons
name="cloudy-outline"
size={14}
color={colors.textSecondary}
/>
<Text
style={[
styles.weatherText,
{ color: colors.textSecondary },
]}
>
{log.weather_description}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
</SectionCard>
);
}
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",
}),
},
});

View File

@@ -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 (
<View style={[styles.container, themedStyles.container]}>
<TouchableOpacity
style={styles.header}
onPress={() => collapsible && setExpanded(!expanded)}
activeOpacity={collapsible ? 0.7 : 1}
disabled={!collapsible}
>
<View style={styles.headerLeft}>
<Ionicons name={icon} size={20} color={themedStyles.icon} />
<Text style={[styles.title, themedStyles.title]}>{title}</Text>
{count !== undefined && (
<View style={[styles.countBadge, themedStyles.count]}>
<Text style={[styles.countText, { color: colors.textSecondary }]}>
{count}
</Text>
</View>
)}
</View>
{collapsible && (
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
{expanded && <View style={styles.content}>{children}</View>}
</View>
);
}
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,
},
});

View File

@@ -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";

View File

@@ -19,7 +19,7 @@ import { queryLastTrip } from "@/controller/TripController";
import { showErrorToast } from "@/services/toast_service"; import { showErrorToast } from "@/services/toast_service";
interface AutoFillSectionProps { interface AutoFillSectionProps {
onAutoFill: (tripData: Model.Trip, selectedShipId: string) => void; onAutoFill: (tripData: Model.Trip, selectedThingId: string) => void;
} }
export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) { export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
@@ -36,7 +36,7 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
things things
?.filter((thing) => thing.id != null) ?.filter((thing) => thing.id != null)
.map((thing) => ({ .map((thing) => ({
id: thing.id as string, thingId: thing.id as string,
shipName: thing.metadata?.ship_name || "", shipName: thing.metadata?.ship_name || "",
})) || []; })) || [];
@@ -46,17 +46,17 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
return ship.shipName.toLowerCase().includes(searchLower); return ship.shipName.toLowerCase().includes(searchLower);
}); });
const handleSelectShip = async (shipId: string) => { const handleSelectShip = async (thingId: string) => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await queryLastTrip(shipId); const response = await queryLastTrip(thingId);
if (response.data) { if (response.data) {
// Close the modal first before showing alert // Close the modal first before showing alert
setIsOpen(false); setIsOpen(false);
setSearchText(""); setSearchText("");
// Pass shipId (thingId) along with trip data for filling ShipSelector // Pass thingId along with trip data for filling ShipSelector
onAutoFill(response.data, shipId); onAutoFill(response.data, thingId);
// Use Alert instead of Toast so it appears above all modals // Use Alert instead of Toast so it appears above all modals
Alert.alert( Alert.alert(
@@ -182,9 +182,9 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
{filteredShips.length > 0 ? ( {filteredShips.length > 0 ? (
filteredShips.map((ship) => ( filteredShips.map((ship) => (
<TouchableOpacity <TouchableOpacity
key={ship.id} key={ship.thingId}
style={[styles.option, themedStyles.option]} style={[styles.option, themedStyles.option]}
onPress={() => handleSelectShip(ship.id)} onPress={() => handleSelectShip(ship.thingId)}
> >
<View style={styles.optionContent}> <View style={styles.optionContent}>
<Ionicons <Ionicons

View File

@@ -12,11 +12,13 @@ import { useThemeContext } from "@/hooks/use-theme-context";
interface BasicInfoInputProps { interface BasicInfoInputProps {
fishingGroundCodes: string; fishingGroundCodes: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean;
} }
export default function BasicInfoInput({ export default function BasicInfoInput({
fishingGroundCodes, fishingGroundCodes,
onChange, onChange,
disabled = false,
}: BasicInfoInputProps) { }: BasicInfoInputProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -25,7 +27,7 @@ export default function BasicInfoInput({
label: { color: colors.text }, label: { color: colors.text },
subLabel: { color: colors.textSecondary }, subLabel: { color: colors.textSecondary },
input: { input: {
backgroundColor: colors.card, backgroundColor: disabled ? colors.backgroundSecondary : colors.card,
borderColor: colors.border, borderColor: colors.border,
color: colors.text, color: colors.text,
}, },
@@ -46,6 +48,7 @@ export default function BasicInfoInput({
placeholder={t("diary.fishingGroundCodesPlaceholder")} placeholder={t("diary.fishingGroundCodesPlaceholder")}
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
keyboardType="numeric" keyboardType="numeric"
editable={!disabled}
/> />
</View> </View>
); );

View File

@@ -20,11 +20,15 @@ interface FishingGear {
interface FishingGearListProps { interface FishingGearListProps {
items: FishingGear[]; items: FishingGear[];
onChange: (items: FishingGear[]) => void; onChange: (items: FishingGear[]) => void;
disabled?: boolean;
hideTitle?: boolean;
} }
export default function FishingGearList({ export default function FishingGearList({
items, items,
onChange, onChange,
disabled = false,
hideTitle = false,
}: FishingGearListProps) { }: FishingGearListProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -75,9 +79,11 @@ export default function FishingGearList({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{!hideTitle && (
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}> <Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.fishingGearList")} {t("diary.fishingGearList")}
</Text> </Text>
)}
{/* Gear Items List */} {/* Gear Items List */}
{items.map((gear, index) => ( {items.map((gear, index) => (
@@ -93,6 +99,7 @@ export default function FishingGearList({
onChangeText={(value) => handleUpdateGear(gear.id, "name", value)} onChangeText={(value) => handleUpdateGear(gear.id, "name", value)}
placeholder={t("diary.gearNamePlaceholder")} placeholder={t("diary.gearNamePlaceholder")}
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
editable={!disabled}
/> />
</View> </View>
@@ -108,10 +115,12 @@ export default function FishingGearList({
placeholder={t("diary.gearNumberPlaceholder")} placeholder={t("diary.gearNumberPlaceholder")}
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
keyboardType="numeric" keyboardType="numeric"
editable={!disabled}
/> />
</View> </View>
{/* Action Buttons */} {/* Action Buttons - hide when disabled */}
{!disabled && (
<View style={styles.actionButtons}> <View style={styles.actionButtons}>
<TouchableOpacity <TouchableOpacity
onPress={() => handleDuplicateGear(gear)} onPress={() => handleDuplicateGear(gear)}
@@ -126,10 +135,12 @@ export default function FishingGearList({
<Ionicons name="trash-outline" size={20} color={colors.error} /> <Ionicons name="trash-outline" size={20} color={colors.error} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)}
</View> </View>
))} ))}
{/* Add Button */} {/* Add Button - hide when disabled */}
{!disabled && (
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, themedStyles.addButton]} style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddGear} onPress={handleAddGear}
@@ -140,6 +151,7 @@ export default function FishingGearList({
{t("diary.addFishingGear")} {t("diary.addFishingGear")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)}
</View> </View>
); );
} }

View File

@@ -0,0 +1,57 @@
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";
interface FormSectionProps {
title: string;
icon?: keyof typeof Ionicons.glyphMap;
children: React.ReactNode;
}
/**
* A styled section wrapper for grouping related form fields
*/
export default function FormSection({ title, icon, children }: FormSectionProps) {
const { colors } = useThemeContext();
return (
<View style={[styles.container, { borderColor: colors.separator }]}>
<View style={styles.header}>
{icon && (
<Ionicons name={icon} size={18} color={colors.primary} style={styles.icon} />
)}
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
</View>
<View style={styles.content}>{children}</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
borderBottomWidth: 1,
paddingBottom: 16,
},
header: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
},
icon: {
marginRight: 8,
},
title: {
fontSize: 17,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
content: {
gap: 0,
},
});

View File

@@ -25,6 +25,8 @@ interface TripCost {
interface MaterialCostListProps { interface MaterialCostListProps {
items: TripCost[]; items: TripCost[];
onChange: (items: TripCost[]) => void; onChange: (items: TripCost[]) => void;
disabled?: boolean;
hideTitle?: boolean;
} }
// Predefined cost types // Predefined cost types
@@ -39,6 +41,8 @@ const COST_TYPES = [
export default function MaterialCostList({ export default function MaterialCostList({
items, items,
onChange, onChange,
disabled = false,
hideTitle = false,
}: MaterialCostListProps) { }: MaterialCostListProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -131,9 +135,11 @@ export default function MaterialCostList({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{!hideTitle && (
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}> <Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.materialCostList")} {t("trip.costTable.title")}
</Text> </Text>
)}
{/* Cost Items List */} {/* Cost Items List */}
{items.map((cost) => ( {items.map((cost) => (
@@ -146,8 +152,9 @@ export default function MaterialCostList({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.dropdown, themedStyles.dropdown]} style={[styles.dropdown, themedStyles.dropdown]}
onPress={() => setTypeDropdownVisible(cost.id)} onPress={disabled ? undefined : () => setTypeDropdownVisible(cost.id)}
activeOpacity={0.7} activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
> >
<Text <Text
style={[ style={[
@@ -158,11 +165,13 @@ export default function MaterialCostList({
> >
{getTypeLabel(cost.type)} {getTypeLabel(cost.type)}
</Text> </Text>
{!disabled && (
<Ionicons <Ionicons
name="chevron-down" name="chevron-down"
size={16} size={16}
color={colors.textSecondary} color={colors.textSecondary}
/> />
)}
</TouchableOpacity> </TouchableOpacity>
{/* Type Dropdown Modal */} {/* Type Dropdown Modal */}
@@ -222,6 +231,7 @@ export default function MaterialCostList({
placeholder="0" placeholder="0"
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
keyboardType="numeric" keyboardType="numeric"
editable={!disabled}
/> />
</View> </View>
@@ -238,6 +248,7 @@ export default function MaterialCostList({
} }
placeholder={t("diary.unitPlaceholder")} placeholder={t("diary.unitPlaceholder")}
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
editable={!disabled}
/> />
</View> </View>
</View> </View>
@@ -261,6 +272,7 @@ export default function MaterialCostList({
placeholder="0" placeholder="0"
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
keyboardType="numeric" keyboardType="numeric"
editable={!disabled}
/> />
</View> </View>
@@ -276,7 +288,8 @@ export default function MaterialCostList({
</View> </View>
</View> </View>
{/* Action Buttons */} {/* Action Buttons - hide when disabled */}
{!disabled && (
<View style={styles.actionButtons}> <View style={styles.actionButtons}>
<TouchableOpacity <TouchableOpacity
onPress={() => handleDuplicateMaterial(cost)} onPress={() => handleDuplicateMaterial(cost)}
@@ -295,11 +308,13 @@ export default function MaterialCostList({
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)}
</View> </View>
</View> </View>
))} ))}
{/* Add Button */} {/* Add Button - hide when disabled */}
{!disabled && (
<TouchableOpacity <TouchableOpacity
style={[styles.addButton, themedStyles.addButton]} style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddMaterial} onPress={handleAddMaterial}
@@ -310,6 +325,7 @@ export default function MaterialCostList({
{t("diary.addMaterialCost")} {t("diary.addMaterialCost")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)}
</View> </View>
); );
} }

View File

@@ -0,0 +1,390 @@
import React, { useState, useEffect, useMemo } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Modal,
ScrollView,
TextInput,
ActivityIndicator,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useGroup } from "@/state/use-group";
import { usePort } from "@/state/use-ports";
import { filterPortsByProvinceCode } from "@/utils/tripDataConverters";
interface PortSelectorProps {
departurePortId: number;
arrivalPortId: number;
onDeparturePortChange: (portId: number) => void;
onArrivalPortChange: (portId: number) => void;
disabled?: boolean;
}
type PortType = "departure" | "arrival";
export default function PortSelector({
departurePortId,
arrivalPortId,
onDeparturePortChange,
onArrivalPortChange,
disabled = false,
}: PortSelectorProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// State from zustand stores
const { groups, getUserGroups, loading: groupsLoading } = useGroup();
const { ports, getPorts, loading: portsLoading } = usePort();
// Local state - which dropdown is open
const [activeDropdown, setActiveDropdown] = useState<PortType | null>(null);
const [searchText, setSearchText] = useState("");
// Fetch groups and ports if not available
useEffect(() => {
if (!groups) {
getUserGroups();
}
}, [groups, getUserGroups]);
useEffect(() => {
if (!ports) {
getPorts();
}
}, [ports, getPorts]);
// Filter ports by province codes from groups
const filteredPorts = useMemo(() => {
return filterPortsByProvinceCode(ports, groups);
}, [ports, groups]);
// Filter by search text
const searchFilteredPorts = useMemo(() => {
if (!searchText) return filteredPorts;
return filteredPorts.filter((port) =>
port.name?.toLowerCase().includes(searchText.toLowerCase())
);
}, [filteredPorts, searchText]);
const isLoading = groupsLoading || portsLoading;
const handleSelectPort = (portId: number) => {
if (activeDropdown === "departure") {
onDeparturePortChange(portId);
} else {
onArrivalPortChange(portId);
}
setActiveDropdown(null);
setSearchText("");
};
const openDropdown = (type: PortType) => {
setActiveDropdown(type);
setSearchText("");
};
const closeDropdown = () => {
setActiveDropdown(null);
setSearchText("");
};
// Get port name by ID
const getPortDisplayName = (portId: number): string => {
const port = filteredPorts.find((p) => p.id === portId);
return port?.name || t("diary.selectPort");
};
const currentSelectedId = activeDropdown === "departure" ? departurePortId : arrivalPortId;
const themedStyles = {
label: { color: colors.text },
portSelector: {
backgroundColor: colors.card,
borderColor: colors.border,
},
portText: { color: colors.text },
placeholder: { color: colors.textSecondary },
modalOverlay: { backgroundColor: "rgba(0, 0, 0, 0.5)" },
modalContent: { backgroundColor: colors.card },
searchInput: {
backgroundColor: colors.backgroundSecondary,
color: colors.text,
borderColor: colors.border,
},
option: { borderBottomColor: colors.separator },
optionText: { color: colors.text },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.portLabel")}
</Text>
<View style={styles.portContainer}>
{/* Departure Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.departurePort")}
</Text>
<TouchableOpacity
style={[styles.dropdown, themedStyles.portSelector]}
onPress={disabled ? undefined : () => openDropdown("departure")}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Text
style={[
styles.dropdownText,
themedStyles.portText,
!departurePortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(departurePortId)}
</Text>
{!disabled && (
<Ionicons
name="chevron-down"
size={16}
color={colors.textSecondary}
/>
)}
</>
)}
</TouchableOpacity>
</View>
{/* Arrival Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.arrivalPort")}
</Text>
<TouchableOpacity
style={[styles.dropdown, themedStyles.portSelector]}
onPress={disabled ? undefined : () => openDropdown("arrival")}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<>
<Text
style={[
styles.dropdownText,
themedStyles.portText,
!arrivalPortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(arrivalPortId)}
</Text>
{!disabled && (
<Ionicons
name="chevron-down"
size={16}
color={colors.textSecondary}
/>
)}
</>
)}
</TouchableOpacity>
</View>
</View>
{/* Port Dropdown Modal - Fade style like MaterialCostList */}
<Modal
visible={activeDropdown !== null}
transparent
animationType="fade"
onRequestClose={closeDropdown}
>
<TouchableOpacity
style={[styles.modalOverlay, themedStyles.modalOverlay]}
activeOpacity={1}
onPress={closeDropdown}
>
<View style={[styles.modalContent, themedStyles.modalContent]}>
{/* Search Input */}
<View style={styles.searchContainer}>
<Ionicons
name="search"
size={18}
color={colors.textSecondary}
style={styles.searchIcon}
/>
<TextInput
style={[styles.searchInput, themedStyles.searchInput]}
placeholder={t("diary.searchPort")}
placeholderTextColor={colors.textSecondary}
value={searchText}
onChangeText={setSearchText}
/>
</View>
{/* Port List */}
<ScrollView style={styles.optionsList}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : searchFilteredPorts.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.noPortsFound")}
</Text>
</View>
) : (
searchFilteredPorts.map((port) => (
<TouchableOpacity
key={port.id}
style={[styles.option, themedStyles.option]}
onPress={() => handleSelectPort(port.id || 0)}
>
<Text style={[styles.optionText, themedStyles.optionText]}>
{port.name}
</Text>
{currentSelectedId === port.id && (
<Ionicons name="checkmark" size={20} color={colors.primary} />
)}
</TouchableOpacity>
))
)}
</ScrollView>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
portContainer: {
flexDirection: "column",
gap: 12,
},
portSection: {
flex: 1,
},
dropdown: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
minHeight: 44,
},
dropdownText: {
fontSize: 15,
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
// Modal styles - same as MaterialCostList
modalOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
modalContent: {
borderRadius: 12,
width: "85%",
maxHeight: "60%",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: "#E5E5E7",
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 15,
paddingVertical: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
optionsList: {
maxHeight: 300,
},
option: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
optionText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
loadingContainer: {
padding: 20,
alignItems: "center",
},
emptyContainer: {
padding: 20,
alignItems: "center",
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -17,11 +17,13 @@ import { useThemeContext } from "@/hooks/use-theme-context";
interface ShipSelectorProps { interface ShipSelectorProps {
selectedShipId: string; selectedShipId: string;
onChange: (shipId: string) => void; onChange: (shipId: string) => void;
disabled?: boolean;
} }
export default function ShipSelector({ export default function ShipSelector({
selectedShipId, selectedShipId,
onChange, onChange,
disabled = false,
}: ShipSelectorProps) { }: ShipSelectorProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -58,7 +60,10 @@ export default function ShipSelector({
const themedStyles = { const themedStyles = {
label: { color: colors.text }, label: { color: colors.text },
selector: { backgroundColor: colors.card, borderColor: colors.border }, selector: {
backgroundColor: disabled ? colors.backgroundSecondary : colors.card,
borderColor: colors.border,
},
selectorText: { color: colors.text }, selectorText: { color: colors.text },
placeholder: { color: colors.textSecondary }, placeholder: { color: colors.textSecondary },
modalContent: { backgroundColor: colors.card }, modalContent: { backgroundColor: colors.card },
@@ -81,8 +86,9 @@ export default function ShipSelector({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.selector, themedStyles.selector]} style={[styles.selector, themedStyles.selector]}
onPress={() => setIsOpen(true)} onPress={() => !disabled && setIsOpen(true)}
activeOpacity={0.7} activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
> >
<Text <Text
style={[ style={[
@@ -93,11 +99,13 @@ export default function ShipSelector({
> >
{displayValue} {displayValue}
</Text> </Text>
{!disabled && (
<Ionicons <Ionicons
name="ellipsis-horizontal" name="ellipsis-horizontal"
size={20} size={20}
color={colors.textSecondary} color={colors.textSecondary}
/> />
)}
</TouchableOpacity> </TouchableOpacity>
<Modal <Modal

View File

@@ -0,0 +1,599 @@
import React, { useState, useEffect } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Modal,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripDurationPickerProps {
startDate: Date | null;
endDate: Date | null;
onStartDateChange: (date: Date | null) => void;
onEndDateChange: (date: Date | null) => void;
disabled?: boolean;
}
type PickerType = "startDate" | "startTime" | "endDate" | "endTime" | null;
export default function TripDurationPicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
disabled = false,
}: TripDurationPickerProps) {
const { t, locale } = useI18n();
const { colors, colorScheme } = useThemeContext();
// Single state for which picker is showing
const [activePicker, setActivePicker] = useState<PickerType>(null);
// Temp states to hold the picker value before confirming
const [tempStartDate, setTempStartDate] = useState<Date>(new Date());
const [tempEndDate, setTempEndDate] = useState<Date>(new Date());
// State hiển thị thời gian hiện tại
const [currentTime, setCurrentTime] = useState<Date>(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");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
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 (!startDate) {
onStartDateChange(today);
}
setTempStartDate(dateToUse);
setActivePicker("startDate");
};
// 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 (!endDate) {
onEndDateChange(today);
}
setTempEndDate(dateToUse);
setActivePicker("endDate");
};
// 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") {
setActivePicker(null);
if (event.type === "set" && selectedDate) {
onStartDateChange(selectedDate);
}
} else if (selectedDate) {
setTempStartDate(selectedDate);
onStartDateChange(selectedDate);
}
};
const handleEndPickerChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") {
setActivePicker(null);
if (event.type === "set" && selectedDate) {
onEndDateChange(selectedDate);
}
} else if (selectedDate) {
setTempEndDate(selectedDate);
onEndDateChange(selectedDate);
}
};
const handleConfirm = () => {
setActivePicker(null);
};
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 = {
label: { color: colors.text },
dateInput: {
backgroundColor: colors.card,
borderColor: colors.border,
},
dateText: { color: colors.text },
placeholder: { color: colors.textSecondary },
pickerContainer: { backgroundColor: colors.card },
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 },
};
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 (
<View style={[styles.sectionCard, themedStyles.sectionCard]}>
{/* Section Header */}
<View style={styles.sectionHeader}>
<View
style={[
styles.iconContainer,
{ backgroundColor: isStart ? "#3B82F620" : "#10B98120" },
]}
>
<Ionicons
name={icon as any}
size={18}
color={isStart ? "#3B82F6" : "#10B981"}
/>
</View>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{title}
</Text>
</View>
{/* Date and Time Row */}
<View style={styles.dateTimeRow}>
{/* Date Picker */}
<TouchableOpacity
style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : onOpenDate}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<View style={styles.inputContent}>
<Ionicons
name="calendar-outline"
size={18}
color={date ? colors.primary : colors.textSecondary}
style={styles.inputIcon}
/>
<View style={styles.inputTextContainer}>
<Text style={[styles.inputLabel, themedStyles.placeholder]}>
{dateLabel}
</Text>
<Text
style={[
styles.inputValue,
themedStyles.dateText,
!date && themedStyles.placeholder,
]}
>
{date ? formatDate(date) : t("diary.selectDate")}
</Text>
</View>
</View>
{!disabled && (
<Ionicons
name="chevron-forward"
size={16}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
{/* Time Picker */}
<TouchableOpacity
style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : onOpenTime}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<View style={styles.inputContent}>
<Ionicons
name="time-outline"
size={18}
color={date ? colors.primary : colors.textSecondary}
style={styles.inputIcon}
/>
<View style={styles.inputTextContainer}>
<Text style={[styles.inputLabel, themedStyles.placeholder]}>
{timeLabel}
</Text>
<Text
style={[
styles.inputValue,
themedStyles.dateText,
!date && themedStyles.placeholder,
]}
>
{date ? formatTime(date) : "--:--"}
</Text>
</View>
</View>
{!disabled && (
<Ionicons
name="chevron-forward"
size={16}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
</View>
</View>
);
};
const isStartPicker =
activePicker === "startDate" || activePicker === "startTime";
const isTimePicker =
activePicker === "startTime" || activePicker === "endTime";
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripDuration")}
</Text>
{/* Hiển thị thời gian hiện tại */}
<View
style={[
styles.currentTimeContainer,
{ backgroundColor: colors.backgroundSecondary || colors.card },
]}
>
<Ionicons name="time-outline" size={18} color={colors.primary} />
<Text
style={[styles.currentTimeLabel, { color: colors.textSecondary }]}
>
{t("diary.currentTime") || "Thời gian hiện tại"}:
</Text>
<Text style={[styles.currentTimeValue, { color: colors.primary }]}>
{formatDate(currentTime)} {formatTime(currentTime)}
</Text>
</View>
{/* Start Section */}
{renderDateTimeSection(
"start",
startDate,
handleOpenStartDatePicker,
handleOpenStartTimePicker
)}
{/* Connection Line */}
<View style={styles.connectionContainer}>
<View
style={[styles.connectionLine, { backgroundColor: colors.border }]}
/>
<View
style={[styles.connectionDot, { backgroundColor: colors.primary }]}
/>
<View
style={[styles.connectionLine, { backgroundColor: colors.border }]}
/>
</View>
{/* End Section */}
{renderDateTimeSection(
"end",
endDate,
handleOpenEndDatePicker,
handleOpenEndTimePicker
)}
{/* iOS: Modal wrapper with spinner */}
{activePicker && Platform.OS === "ios" && (
<Modal transparent animationType="fade" visible={!!activePicker}>
<View style={styles.modalOverlay}>
<View
style={[styles.pickerContainer, themedStyles.pickerContainer]}
>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={handleCancel}>
<Text
style={[styles.cancelButtonText, themedStyles.cancelButton]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{getPickerTitle()}
</Text>
<TouchableOpacity onPress={handleConfirm}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={isStartPicker ? tempStartDate : tempEndDate}
mode={isTimePicker ? "time" : "date"}
display="spinner"
onChange={
isStartPicker
? handleStartPickerChange
: handleEndPickerChange
}
maximumDate={
isStartPicker && !isTimePicker
? endDate || undefined
: undefined
}
minimumDate={
!isTimePicker
? isStartPicker
? new Date()
: startDate || new Date()
: undefined
}
themeVariant={colorScheme}
textColor={colors.text}
locale={locale}
/>
</View>
</View>
</Modal>
)}
{/* Android: Native dialog (no Modal wrapper needed) */}
{activePicker && Platform.OS === "android" && (
<DateTimePicker
value={isStartPicker ? tempStartDate : tempEndDate}
mode={isTimePicker ? "time" : "date"}
display="default"
onChange={
isStartPicker ? handleStartPickerChange : handleEndPickerChange
}
maximumDate={
isStartPicker && !isTimePicker ? endDate || undefined : undefined
}
minimumDate={
!isTimePicker
? isStartPicker
? new Date()
: startDate || new Date()
: undefined
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
sectionCard: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
},
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,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
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)",
justifyContent: "flex-end",
},
pickerContainer: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 20,
},
pickerHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
pickerTitle: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
cancelButtonText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
doneButton: {
fontSize: 16,
fontWeight: "600",
color: "#3B82F6",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
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",
}),
},
});

View File

@@ -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 (
<View style={styles.container}>
{/* Auto Fill Section - only show in add mode */}
{!isEditMode && !isViewMode && onAutoFill && (
<AutoFillSection onAutoFill={onAutoFill} />
)}
{/* Ship Selector - disabled in edit and view mode */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
disabled={isEditMode || isViewMode}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
{/* Fishing Gear List */}
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
{/* Trip Cost List */}
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
disabled={isReadOnly}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
disabled={isReadOnly}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
disabled={isReadOnly}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@@ -12,16 +12,17 @@ import { useThemeContext } from "@/hooks/use-theme-context";
interface TripNameInputProps { interface TripNameInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean;
} }
export default function TripNameInput({ value, onChange }: TripNameInputProps) { export default function TripNameInput({ value, onChange, disabled = false }: TripNameInputProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const themedStyles = { const themedStyles = {
label: { color: colors.text }, label: { color: colors.text },
input: { input: {
backgroundColor: colors.card, backgroundColor: disabled ? colors.backgroundSecondary : colors.card,
borderColor: colors.border, borderColor: colors.border,
color: colors.text, color: colors.text,
}, },
@@ -38,6 +39,7 @@ export default function TripNameInput({ value, onChange }: TripNameInputProps) {
onChangeText={onChange} onChangeText={onChange}
placeholder={t("diary.tripNamePlaceholder")} placeholder={t("diary.tripNamePlaceholder")}
placeholderTextColor={colors.textSecondary} placeholderTextColor={colors.textSecondary}
editable={!disabled}
/> />
</View> </View>
); );

View File

@@ -0,0 +1,587 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ScrollView,
Alert,
ActivityIndicator,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import FishingGearList from "./FishingGearList";
import MaterialCostList from "./MaterialCostList";
import TripNameInput from "./TripNameInput";
import TripDurationPicker from "./TripDurationPicker";
import PortSelector from "./PortSelector";
import BasicInfoInput from "./BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
import FormSection from "./FormSection";
import { createTrip, updateTrip } from "@/controller/TripController";
import {
convertFishingGears,
convertTripCosts,
convertFishingGroundCodes,
parseFishingGroundCodes,
} from "@/utils/tripDataConverters";
// Internal component interfaces - extend from Model with local id for state management
export interface FishingGear extends Model.FishingGear {
id: string;
}
export interface TripCost extends Model.TripCost {
id: string;
}
interface TripFormModalProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
mode?: "add" | "edit";
tripData?: Model.Trip;
}
// Default form state
const DEFAULT_FORM_STATE = {
selectedShipId: "",
tripName: "",
fishingGears: [] as FishingGear[],
tripCosts: [] as TripCost[],
startDate: null as Date | null,
endDate: null as Date | null,
departurePortId: 1,
arrivalPortId: 1,
fishingGroundCodes: "",
};
export default function TripFormModal({
visible,
onClose,
onSuccess,
mode = "add",
tripData,
}: TripFormModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const isEditMode = mode === "edit";
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(
new Animated.Value(Dimensions.get("window").height)
).current;
// Form state
const [selectedShipId, setSelectedShipId] = useState(
DEFAULT_FORM_STATE.selectedShipId
);
const [tripName, setTripName] = useState(DEFAULT_FORM_STATE.tripName);
const [fishingGears, setFishingGears] = useState<FishingGear[]>(
DEFAULT_FORM_STATE.fishingGears
);
const [tripCosts, setTripCosts] = useState<TripCost[]>(
DEFAULT_FORM_STATE.tripCosts
);
const [startDate, setStartDate] = useState<Date | null>(
DEFAULT_FORM_STATE.startDate
);
const [endDate, setEndDate] = useState<Date | null>(
DEFAULT_FORM_STATE.endDate
);
const [departurePortId, setDeparturePortId] = useState(
DEFAULT_FORM_STATE.departurePortId
);
const [arrivalPortId, setArrivalPortId] = useState(
DEFAULT_FORM_STATE.arrivalPortId
);
const [fishingGroundCodes, setFishingGroundCodes] = useState(
DEFAULT_FORM_STATE.fishingGroundCodes
);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form to default state
const resetForm = useCallback(() => {
setSelectedShipId(DEFAULT_FORM_STATE.selectedShipId);
setTripName(DEFAULT_FORM_STATE.tripName);
setFishingGears(DEFAULT_FORM_STATE.fishingGears);
setTripCosts(DEFAULT_FORM_STATE.tripCosts);
setStartDate(DEFAULT_FORM_STATE.startDate);
setEndDate(DEFAULT_FORM_STATE.endDate);
setDeparturePortId(DEFAULT_FORM_STATE.departurePortId);
setArrivalPortId(DEFAULT_FORM_STATE.arrivalPortId);
setFishingGroundCodes(DEFAULT_FORM_STATE.fishingGroundCodes);
}, []);
// Fill form with trip data
const fillFormWithTripData = useCallback(
(data: Model.Trip, prefix: string) => {
setSelectedShipId(data.vms_id || "");
setTripName(data.name || "");
setFishingGears(convertFishingGears(data.fishing_gears, prefix));
setTripCosts(convertTripCosts(data.trip_cost, prefix));
if (data.departure_time) setStartDate(new Date(data.departure_time));
if (data.arrival_time) setEndDate(new Date(data.arrival_time));
if (data.departure_port_id) setDeparturePortId(data.departure_port_id);
if (data.arrival_port_id) setArrivalPortId(data.arrival_port_id);
setFishingGroundCodes(
convertFishingGroundCodes(data.fishing_ground_codes)
);
},
[]
);
// Handle animation when modal visibility changes
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
// Pre-fill form when in edit mode
useEffect(() => {
if (isEditMode && tripData && visible) {
fillFormWithTripData(tripData, "edit");
}
}, [isEditMode, tripData, visible, fillFormWithTripData]);
// Close modal with animation
const closeWithAnimation = useCallback(
(shouldResetForm: boolean = true) => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
onClose();
if (shouldResetForm) {
setTimeout(resetForm, 100);
}
});
},
[fadeAnim, slideAnim, onClose, resetForm]
);
// Handle cancel
const handleCancel = useCallback(() => {
closeWithAnimation(true);
}, [closeWithAnimation]);
// Handle close after successful submit
const handleSuccessClose = useCallback(() => {
fadeAnim.setValue(0);
slideAnim.setValue(Dimensions.get("window").height);
onClose();
setTimeout(resetForm, 100);
}, [fadeAnim, slideAnim, onClose, resetForm]);
// Handle auto-fill from last trip data
const handleAutoFill = useCallback(
(tripData: Model.Trip, selectedThingId: string) => {
setSelectedShipId(selectedThingId);
setFishingGears(convertFishingGears(tripData.fishing_gears, "auto"));
setTripCosts(convertTripCosts(tripData.trip_cost, "auto"));
if (tripData.departure_port_id)
setDeparturePortId(tripData.departure_port_id);
if (tripData.arrival_port_id) setArrivalPortId(tripData.arrival_port_id);
setFishingGroundCodes(
convertFishingGroundCodes(tripData.fishing_ground_codes)
);
},
[]
);
// Validate form
const validateForm = useCallback((): boolean => {
if (!selectedShipId) {
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
return false;
}
if (!startDate || !endDate) {
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return false;
}
// Validate: thời điểm khởi hành phải từ hiện tại trở đi
const now = new Date();
const startMinutes = Math.floor(startDate.getTime() / 60000);
const nowMinutes = Math.floor(now.getTime() / 60000);
if (startMinutes < nowMinutes) {
Alert.alert(
t("common.error"),
t("diary.validation.startDateNotInPast") ||
"Thời điểm khởi hành không được ở quá khứ"
);
return false;
}
// Validate: thời điểm kết thúc phải sau thời điểm khởi hành
if (endDate <= startDate) {
Alert.alert(
t("common.error"),
t("diary.validation.endDateAfterStart") ||
"Thời điểm kết thúc phải sau thời điểm khởi hành"
);
return false;
}
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return false;
}
return true;
}, [selectedShipId, startDate, endDate, tripName, t]);
// Build API body
const buildApiBody = useCallback((): Model.TripAPIBody => {
return {
name: tripName,
departure_time: startDate?.toISOString() || "",
departure_port_id: departurePortId,
arrival_time: endDate?.toISOString() || "",
arrival_port_id: arrivalPortId,
fishing_ground_codes: parseFishingGroundCodes(fishingGroundCodes),
fishing_gears: fishingGears.map((gear) => ({
name: gear.name,
number: gear.number,
})),
trip_cost: tripCosts.map((cost) => ({
type: cost.type,
amount: cost.amount,
unit: cost.unit,
cost_per_unit: cost.cost_per_unit,
total_cost: cost.total_cost,
})),
};
}, [
tripName,
startDate,
endDate,
departurePortId,
arrivalPortId,
fishingGroundCodes,
fishingGears,
tripCosts,
]);
// Handle form submit
const handleSubmit = useCallback(async () => {
if (!validateForm()) return;
const apiBody = buildApiBody();
setIsSubmitting(true);
try {
if (isEditMode && tripData) {
await updateTrip(tripData.id, apiBody);
} else {
await createTrip(selectedShipId, apiBody);
}
onSuccess?.();
Alert.alert(
t("common.success"),
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
);
handleSuccessClose();
} catch (error: any) {
console.error(
isEditMode ? "Error updating trip:" : "Error creating trip:",
error
);
// Lấy message từ server response
const serverMessage = error.response?.data || "";
// Kiểm tra lỗi cụ thể: trip already exists
if (
serverMessage.includes &&
serverMessage.includes("already exists and not completed")
) {
// Đánh dấu lỗi đã được xử lý (axios sẽ không hiển thị toast cho status 400)
Alert.alert(
t("common.warning") || "Cảnh báo",
t("diary.tripAlreadyExistsError") ||
"Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới."
);
} else {
// Các lỗi khác
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
);
}
} finally {
setIsSubmitting(false);
}
}, [
validateForm,
buildApiBody,
isEditMode,
tripData,
selectedShipId,
onSuccess,
t,
handleSuccessClose,
]);
const themedStyles = {
modalContainer: { backgroundColor: colors.card },
header: { borderBottomColor: colors.separator },
title: { color: colors.text },
footer: { borderTopColor: colors.separator },
cancelButton: { backgroundColor: colors.backgroundSecondary },
cancelButtonText: { color: colors.textSecondary },
submitButton: { backgroundColor: colors.primary },
};
return (
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleCancel}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{isEditMode ? t("diary.editTrip") : t("diary.addTrip")}
</Text>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Auto Fill Section - only show in add mode */}
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* Section 1: Basic Information */}
<FormSection
title={t("diary.formSection.basicInfo")}
icon="boat-outline"
>
{/* Ship Selector - disabled in edit mode */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
disabled={isEditMode}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} />
</FormSection>
{/* Section 2: Schedule & Location */}
<FormSection
title={t("diary.formSection.schedule")}
icon="calendar-outline"
>
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
/>
</FormSection>
{/* Section 3: Equipment */}
<FormSection
title={t("diary.formSection.equipment")}
icon="construct-outline"
>
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
hideTitle
/>
</FormSection>
{/* Section 4: Costs */}
<FormSection
title={t("diary.formSection.costs")}
icon="wallet-outline"
>
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
hideTitle
/>
</FormSection>
</ScrollView>
{/* Footer */}
<View style={[styles.footer, themedStyles.footer]}>
<TouchableOpacity
style={[styles.cancelButton, themedStyles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text
style={[styles.cancelButtonText, themedStyles.cancelButtonText]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
themedStyles.submitButton,
isSubmitting && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
activeOpacity={0.7}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}>
{isEditMode ? t("diary.saveChanges") : t("diary.createTrip")}
</Text>
)}
</TouchableOpacity>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
shadowColor: "#000",
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
placeholder: {
width: 32,
},
content: {
padding: 20,
},
footer: {
flexDirection: "row",
gap: 12,
padding: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,171 +0,0 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
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 PortSelectorProps {
departurePortId: number;
arrivalPortId: number;
onDeparturePortChange: (portId: number) => void;
onArrivalPortChange: (portId: number) => void;
}
export default function PortSelector({
departurePortId,
arrivalPortId,
onDeparturePortChange,
onArrivalPortChange,
}: PortSelectorProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const handleSelectDeparturePort = () => {
console.log("Select departure port pressed");
// TODO: Implement port selection modal/dropdown
// For now, just set a dummy ID
onDeparturePortChange(1);
};
const handleSelectArrivalPort = () => {
console.log("Select arrival port pressed");
// TODO: Implement port selection modal/dropdown
// For now, just set a dummy ID
onArrivalPortChange(1);
};
// Helper to display port name (in production, fetch from port list by ID)
const getPortDisplayName = (portId: number): string => {
// TODO: Fetch actual port name by ID from port list
return portId ? `Cảng (ID: ${portId})` : t("diary.selectPort");
};
const themedStyles = {
label: { color: colors.text },
portSelector: {
backgroundColor: colors.card,
borderColor: colors.border,
},
portText: { color: colors.text },
placeholder: { color: colors.textSecondary },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.portLabel")}
</Text>
<View style={styles.portContainer}>
{/* Departure Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.departurePort")}
</Text>
<TouchableOpacity
style={[styles.portSelector, themedStyles.portSelector]}
onPress={handleSelectDeparturePort}
activeOpacity={0.7}
>
<Text
style={[
styles.portText,
themedStyles.portText,
!departurePortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(departurePortId)}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* Arrival Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.arrivalPort")}
</Text>
<TouchableOpacity
style={[styles.portSelector, themedStyles.portSelector]}
onPress={handleSelectArrivalPort}
activeOpacity={0.7}
>
<Text
style={[
styles.portText,
themedStyles.portText,
!arrivalPortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(arrivalPortId)}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
portContainer: {
flexDirection: "row",
gap: 12,
},
portSection: {
flex: 1,
},
portSelector: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
portText: {
fontSize: 15,
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,289 +0,0 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Modal,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripDurationPickerProps {
startDate: Date | null;
endDate: Date | null;
onStartDateChange: (date: Date | null) => void;
onEndDateChange: (date: Date | null) => void;
}
export default function TripDurationPicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
}: TripDurationPickerProps) {
const { t } = useI18n();
const { colors, colorScheme } = useThemeContext();
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const formatDate = (date: Date | null) => {
if (!date) return "";
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
const handleStartDateChange = (event: any, selectedDate?: Date) => {
setShowStartPicker(Platform.OS === "ios");
if (selectedDate) {
onStartDateChange(selectedDate);
}
};
const handleEndDateChange = (event: any, selectedDate?: Date) => {
setShowEndPicker(Platform.OS === "ios");
if (selectedDate) {
onEndDateChange(selectedDate);
}
};
const themedStyles = {
label: { color: colors.text },
dateInput: {
backgroundColor: colors.card,
borderColor: colors.border,
},
dateText: { color: colors.text },
placeholder: { color: colors.textSecondary },
pickerContainer: { backgroundColor: colors.card },
pickerHeader: { borderBottomColor: colors.border },
pickerTitle: { color: colors.text },
cancelButton: { color: colors.textSecondary },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripDuration")}
</Text>
<View style={styles.dateRangeContainer}>
{/* Start Date */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.startDate")}
</Text>
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowStartPicker(true)}
activeOpacity={0.7}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!startDate && themedStyles.placeholder,
]}
>
{startDate ? formatDate(startDate) : t("diary.selectDate")}
</Text>
<Ionicons
name="calendar-outline"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* End Date */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.endDate")}
</Text>
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowEndPicker(true)}
activeOpacity={0.7}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!endDate && themedStyles.placeholder,
]}
>
{endDate ? formatDate(endDate) : t("diary.selectDate")}
</Text>
<Ionicons
name="calendar-outline"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
</View>
{/* Start Date Picker */}
{showStartPicker && (
<Modal transparent animationType="fade" visible={showStartPicker}>
<View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectStartDate")}
</Text>
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={startDate || new Date()}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleStartDateChange}
maximumDate={endDate || undefined}
themeVariant={colorScheme}
textColor={colors.text}
/>
</View>
</View>
</Modal>
)}
{/* End Date Picker */}
{showEndPicker && (
<Modal transparent animationType="fade" visible={showEndPicker}>
<View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectEndDate")}
</Text>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={endDate || new Date()}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleEndDateChange}
minimumDate={startDate || undefined}
themeVariant={colorScheme}
textColor={colors.text}
/>
</View>
</View>
</Modal>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
dateRangeContainer: {
flexDirection: "row",
gap: 12,
},
dateSection: {
flex: 1,
},
dateInput: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
dateText: {
fontSize: 15,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
pickerContainer: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 20,
},
pickerHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
pickerTitle: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
cancelButton: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
doneButton: {
fontSize: 16,
fontWeight: "600",
color: "#3B82F6",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,386 +0,0 @@
import React, { useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ScrollView,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import FishingGearList from "@/components/diary/addTripModal/FishingGearList";
import MaterialCostList from "@/components/diary/addTripModal/MaterialCostList";
import TripNameInput from "@/components/diary/addTripModal/TripNameInput";
import TripDurationPicker from "@/components/diary/addTripModal/TripDurationPicker";
import PortSelector from "@/components/diary/addTripModal/PortSelector";
import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
// Internal component interfaces
export interface FishingGear {
id: string;
name: string;
number: string; // Changed from quantity to number (string)
}
export interface TripCost {
id: string;
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}
// API body interface
export interface TripAPIBody {
thing_id?: string; // Ship ID
name: string;
departure_time: string; // ISO string
departure_port_id: number;
arrival_time: string; // ISO string
arrival_port_id: number;
fishing_ground_codes: number[]; // Array of numbers
fishing_gears: Array<{
name: string;
number: string;
}>;
trip_cost: Array<{
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}>;
}
interface AddTripModalProps {
visible: boolean;
onClose: () => void;
}
export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Form state
const [selectedShipId, setSelectedShipId] = useState<string>("");
const [tripName, setTripName] = useState("");
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [departurePortId, setDeparturePortId] = useState<number>(1);
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // Input as string, convert to array
const handleCancel = () => {
// Reset form
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
onClose();
};
// Handle auto-fill from last trip data
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
setSelectedShipId(selectedThingId);
// Fill trip name
if (tripData.name) {
setTripName(tripData.name);
}
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
id: `auto-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
}));
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `auto-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill departure and arrival ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
};
const handleSubmit = () => {
// 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: TripAPIBody = {
thing_id: selectedShipId || undefined,
name: tripName,
departure_time: startDate ? startDate.toISOString() : "",
departure_port_id: departurePortId,
arrival_time: endDate ? endDate.toISOString() : "",
arrival_port_id: arrivalPortId,
fishing_ground_codes: fishingGroundCodesArray,
fishing_gears: fishingGears.map((gear) => ({
name: gear.name,
number: gear.number,
})),
trip_cost: tripCosts.map((cost) => ({
type: cost.type,
amount: cost.amount,
unit: cost.unit,
cost_per_unit: cost.cost_per_unit,
total_cost: cost.total_cost,
})),
};
// Simulate API call - log the formatted data
console.log("=== Submitting Trip Data (API Format) ===");
console.log(JSON.stringify(apiBody, null, 2));
console.log("=== End Trip Data ===");
// Reset form and close modal
handleCancel();
};
const themedStyles = {
modalContainer: {
backgroundColor: colors.card,
},
header: {
borderBottomColor: colors.separator,
},
title: {
color: colors.text,
},
footer: {
borderTopColor: colors.separator,
},
cancelButton: {
backgroundColor: colors.backgroundSecondary,
},
cancelButtonText: {
color: colors.textSecondary,
},
submitButton: {
backgroundColor: colors.primary,
},
};
return (
<Modal
visible={visible}
animationType="fade"
transparent
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={[styles.modalContainer, themedStyles.modalContainer]}>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.addTrip")}
</Text>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Auto Fill Section */}
<AutoFillSection onAutoFill={handleAutoFill} />
{/* Ship Selector */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} />
{/* Fishing Gear List */}
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
/>
{/* Trip Cost List */}
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
/>
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
/>
</ScrollView>
{/* Footer */}
<View style={[styles.footer, themedStyles.footer]}>
<TouchableOpacity
style={[styles.cancelButton, themedStyles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, themedStyles.submitButton]}
onPress={handleSubmit}
activeOpacity={0.7}
>
<Text style={styles.submitButtonText}>
{t("diary.createTrip")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
placeholder: {
width: 32,
},
content: {
padding: 20,
},
footer: {
flexDirection: "row",
gap: 12,
padding: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
submitButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,16 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { StyleSheet } from "react-native";
export default function DevicesScreen() {
console.log("Gọi API 2");
return (
<ThemedView style={styles.container}>
<ThemedText>Quản thiết bị</ThemedText>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});

View File

@@ -0,0 +1,17 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { StyleSheet } from "react-native";
export default function FleetsScreen() {
console.log("Gọi API 3");
return (
<ThemedView style={styles.container}>
<ThemedText>Quản đi tàu</ThemedText>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});

View File

@@ -0,0 +1,853 @@
import Select, { SelectOption } from "@/components/Select";
import { ThemedText } from "@/components/themed-text";
import { Colors } from "@/config";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { usePort } from "@/state/use-ports";
import { useShipGroups } from "@/state/use-ship-groups";
import { useShipTypes } from "@/state/use-ship-types";
import { useThings } from "@/state/use-thing";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
interface CreateOrUpdateShipProps {
initialValue?: Model.ShipBodyRequest;
isOpen?: boolean;
type?: "create" | "update";
onSubmit?: (data: Model.ShipBodyRequest) => void;
onClose?: () => void;
}
const CreateOrUpdateShip = (props: CreateOrUpdateShipProps) => {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const { shipTypes, getShipTypes } = useShipTypes();
const { ports, getPorts } = usePort();
const { shipGroups, getShipGroups } = useShipGroups();
const { things, getThings } = useThings();
// State for date picker
const [showDatePicker, setShowDatePicker] = useState(false);
// Initialize form with react-hook-form
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm<Model.ShipBodyRequest>({
defaultValues: props.initialValue || {
fishing_license_expiry_date: new Date(),
},
});
// Watch the date field for picker display
const dateValue = watch("fishing_license_expiry_date");
// Fetch data when modal opens
useEffect(() => {
if (props.isOpen) {
// Fetch ship types if not loaded
if (shipTypes === null || shipTypes.length === 0) {
getShipTypes();
}
// Fetch ports if not loaded
if (ports === null) {
getPorts();
}
// Fetch ship groups if not loaded
if (shipGroups === null) {
getShipGroups();
}
// Fetch things when modal opens
const payloadThings: Model.SearchThingBody = {
offset: 0,
limit: 200,
order: "name",
dir: "asc",
};
getThings(payloadThings);
// Reset form with initial values if provided
if (props.initialValue) {
reset(props.initialValue);
}
}
}, [props.isOpen, props.initialValue, reset]);
useEffect(() => {
if (props.type === "create") {
reset(props.initialValue);
}
}, [props.isOpen]);
// Prepare options for selects
const shipTypeOptions = useMemo<SelectOption[]>(() => {
return (shipTypes || []).map((type) => ({
label: type.name || "",
value: type.id || 0,
}));
}, [shipTypes]);
const portOptions = useMemo<SelectOption[]>(() => {
return (ports?.ports || []).map((port) => ({
label: port.name || "",
value: port.id || 0,
}));
}, [ports]);
const shipGroupOptions = useMemo<SelectOption[]>(() => {
return (shipGroups || []).map((group) => ({
label: group.name || "",
value: group.id || "",
}));
}, [shipGroups]);
const thingOptions = useMemo<SelectOption[]>(() => {
// Filter things that are not assigned to any ship
const unassignedThings = (things || []).filter(
(thing) => !thing.metadata?.ship_id
);
return unassignedThings.map((thing) => ({
label: thing.name || "",
value: thing.id || "",
}));
}, [things]);
// Handle date picker change
const handleDateChange = (_: any, selectedDate?: Date) => {
if (selectedDate) {
setValue("fishing_license_expiry_date", selectedDate);
}
// On Android, close picker after selection
// On iOS, keep it open until user confirms with the button
if (Platform.OS === "android") {
setShowDatePicker(false);
}
};
// Format date for display
const formatDateForDisplay = (date: Date | string | undefined) => {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
// Handle form submission
const onSubmit = (data: Model.ShipBodyRequest) => {
// Ensure numeric fields are numbers
const payload: Model.ShipBodyRequest = {
...data,
ship_type: Number(data.ship_type),
home_port: Number(data.home_port),
ship_length: Number(data.ship_length),
ship_power: Number(data.ship_power),
fishing_license_expiry_date: data.fishing_license_expiry_date,
};
props.onSubmit?.(payload);
};
return (
<Modal
animationType="slide"
transparent={true}
visible={props.isOpen}
onRequestClose={props.onClose}
>
<SafeAreaView style={{ flex: 1 }} edges={["top", "left", "right"]}>
<View style={styles.container}>
<Pressable style={styles.backdrop} onPress={props.onClose} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.keyboardAvoidingView}
>
<View style={styles.modalContent}>
{/* Header */}
<View style={styles.header}>
<View style={styles.dragIndicator} />
<ThemedText style={styles.headerTitle}>
{props.type === "create" ? "Thêm tàu mới" : "Cập nhật tàu"}
</ThemedText>
<TouchableOpacity
onPress={props.onClose}
style={styles.closeButton}
>
<ThemedText style={styles.closeButtonText}></ThemedText>
</TouchableOpacity>
</View>
{/* Form Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Registration Number - Only show in create mode */}
{props.type === "create" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Số đăng *</ThemedText>
<Controller
control={control}
name="reg_number"
rules={{ required: "Vui lòng nhập số đăng ký" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.reg_number
? "red"
: colors.border,
},
]}
placeholder="Nhập số đăng ký"
onBlur={onBlur}
onChangeText={(text) => onChange(text.trim())}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.reg_number && (
<ThemedText style={styles.errorText}>
{errors.reg_number.message}
</ThemedText>
)}
</View>
)}
{/* Ship Name */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Tên tàu *</ThemedText>
<Controller
control={control}
name="name"
rules={{ required: "Vui lòng nhập tên tàu" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{ borderColor: errors.name ? "red" : colors.border },
]}
placeholder="Nhập tên tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.name && (
<ThemedText style={styles.errorText}>
{errors.name.message}
</ThemedText>
)}
</View>
{/* Ship Type */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Loại tàu *</ThemedText>
<Controller
control={control}
name="ship_type"
rules={{ required: "Vui lòng chọn loại tàu" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={shipTypeOptions}
placeholder="Chọn loại tàu"
style={[
styles.selectInput,
{
borderColor: errors.ship_type
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.ship_type && (
<ThemedText style={styles.errorText}>
{errors.ship_type.message}
</ThemedText>
)}
</View>
{/* Home Port */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Cảng đăng đ *
</ThemedText>
<Controller
control={control}
name="home_port"
rules={{ required: "Vui lòng chọn cảng đăng ký" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={portOptions}
placeholder="Chọn cảng đăng ký"
style={[
styles.selectInput,
{
borderColor: errors.home_port
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.home_port && (
<ThemedText style={styles.errorText}>
{errors.home_port.message}
</ThemedText>
)}
</View>
{/* Fishing License Number */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Số giấy phép *</ThemedText>
<Controller
control={control}
name="fishing_license_number"
rules={{ required: "Vui lòng nhập số giấy phép" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.fishing_license_number
? "red"
: colors.border,
},
]}
placeholder="Nhập số giấy phép"
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.fishing_license_number && (
<ThemedText style={styles.errorText}>
{errors.fishing_license_number.message}
</ThemedText>
)}
</View>
{/* Fishing License Expiry Date */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Ngày hết hạn *</ThemedText>
<Controller
control={control}
name="fishing_license_expiry_date"
rules={{
required: "Vui lòng chọn ngày hết hạn",
validate: (date) => {
if (!date) return "Vui lòng chọn ngày hết hạn";
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
return "Ngày hết hạn không thể là ngày trong quá khứ";
}
return true;
},
}}
render={({ field: { onChange, value } }) => (
<TouchableOpacity
onPress={() => setShowDatePicker(true)}
style={[
styles.input,
styles.dateInput,
{
borderColor: errors.fishing_license_expiry_date
? "red"
: colors.border,
},
]}
>
<ThemedText
style={{
color: value ? colors.text : colors.textSecondary,
}}
>
{formatDateForDisplay(value) || "Chọn ngày hết hạn"}
</ThemedText>
</TouchableOpacity>
)}
/>
{errors.fishing_license_expiry_date && (
<ThemedText style={styles.errorText}>
{errors.fishing_license_expiry_date.message}
</ThemedText>
)}
</View>
{/* Ship Length */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Chiều dài (m) *</ThemedText>
<Controller
control={control}
name="ship_length"
rules={{
required: "Vui lòng nhập chiều dài",
pattern: {
value: /^\d*\.?\d+$/,
message: "Vui lòng nhập số hợp lệ",
},
validate: (value) => {
const num = Number(value);
if (isNaN(num) || num <= 0) {
return "Chiều dài phải lớn hơn 0";
}
return true;
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.ship_length
? "red"
: colors.border,
},
]}
placeholder="Nhập chiều dài tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value?.toString()}
keyboardType="numeric"
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.ship_length && (
<ThemedText style={styles.errorText}>
{errors.ship_length.message}
</ThemedText>
)}
</View>
{/* Ship Power */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Công suất ( lực) *
</ThemedText>
<Controller
control={control}
name="ship_power"
rules={{
required: "Vui lòng nhập công suất",
pattern: {
value: /^\d*\.?\d+$/,
message: "Vui lòng nhập số hợp lệ",
},
validate: (value) => {
const num = Number(value);
if (isNaN(num) || num <= 0) {
return "Công suất phải lớn hơn 0";
}
return true;
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.ship_power
? "red"
: colors.border,
},
]}
placeholder="Nhập công suất tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value?.toString()}
keyboardType="numeric"
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.ship_power && (
<ThemedText style={styles.errorText}>
{errors.ship_power.message}
</ThemedText>
)}
</View>
{/* Ship Group - Only show in update mode */}
{props.type === "update" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Đi tàu</ThemedText>
<Controller
control={control}
name="ship_group_id"
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={shipGroupOptions}
placeholder="Chọn đội tàu"
style={styles.selectInput}
/>
)}
/>
</View>
)}
{/* Device/Thing - Only show in create mode */}
{props.type === "create" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Thiết bị kết nối *
</ThemedText>
<Controller
control={control}
name="thing_id"
rules={{ required: "Vui lòng chọn thiết bị kết nối" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={thingOptions}
placeholder="Chọn thiết bị kết nối"
style={[
styles.selectInput,
{
borderColor: errors.thing_id
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.thing_id && (
<ThemedText style={styles.errorText}>
{errors.thing_id.message}
</ThemedText>
)}
</View>
)}
</ScrollView>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.resetButton, { borderColor: colors.border }]}
onPress={() => {
reset(props.initialValue || {});
}}
>
<ThemedText
style={[styles.resetButtonText, { color: colors.text }]}
>
Nhập lại
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onSubmit)}
>
<ThemedText style={styles.submitButtonText}>
{props.type === "create" ? "Thêm tàu" : "Cập nhật"}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</SafeAreaView>
{/* Date Picker Modal - Only show on Android as modal, iOS shows inline */}
{Platform.OS === "android" && showDatePicker && (
<DateTimePicker
value={
typeof dateValue === "string"
? new Date(dateValue)
: dateValue || new Date()
}
mode="date"
display="default"
onChange={handleDateChange}
minimumDate={new Date()}
/>
)}
{Platform.OS === "ios" && showDatePicker && (
<Modal
transparent={true}
animationType="fade"
visible={showDatePicker}
onRequestClose={() => setShowDatePicker(false)}
>
<SafeAreaView style={styles.datePickerModal}>
<View style={styles.datePickerContent}>
<View style={styles.datePickerHeader}>
<ThemedText style={styles.datePickerTitle}>
Chọn ngày hết hạn
</ThemedText>
<TouchableOpacity onPress={() => setShowDatePicker(false)}>
<ThemedText style={styles.datePickerClose}></ThemedText>
</TouchableOpacity>
</View>
<DateTimePicker
value={
typeof dateValue === "string"
? new Date(dateValue)
: dateValue || new Date()
}
mode="date"
display="spinner"
onChange={handleDateChange}
themeVariant={colorScheme}
textColor={colors.text}
minimumDate={new Date()}
style={styles.datePickerIOS}
/>
<TouchableOpacity
style={[
styles.datePickerButton,
{ backgroundColor: colors.primary },
]}
onPress={() => setShowDatePicker(false)}
>
<ThemedText style={styles.datePickerButtonText}>
Xác nhận
</ThemedText>
</TouchableOpacity>
</View>
</SafeAreaView>
</Modal>
)}
</Modal>
);
};
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
container: {
flex: 1,
position: "relative",
},
keyboardAvoidingView: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)",
},
modalContent: {
height: "90%",
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 10,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: colors.border,
position: "relative",
},
dragIndicator: {
position: "absolute",
top: 8,
width: 40,
height: 4,
backgroundColor: colors.border,
borderRadius: 2,
},
headerTitle: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
color: colors.text,
},
closeButton: {
position: "absolute",
right: 16,
top: 16,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
},
closeButtonText: {
fontSize: 20,
fontWeight: "300",
color: colors.text,
},
scrollView: {
flex: 1,
padding: 20,
},
scrollContent: {
paddingBottom: Platform.OS === "ios" ? 120 : 80,
},
fieldGroup: {
marginBottom: 24,
},
label: {
fontSize: 15,
fontWeight: "600",
marginBottom: 8,
color: colors.text,
},
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
backgroundColor: colors.surface,
color: colors.text,
},
selectInput: {
borderWidth: 1,
borderRadius: 12,
backgroundColor: colors.surface,
},
dateInput: {
justifyContent: "center",
},
errorText: {
fontSize: 13,
color: "red",
marginTop: 4,
},
actionButtons: {
flexDirection: "row",
paddingHorizontal: 20,
paddingVertical: 16,
gap: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
resetButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
resetButtonText: {
fontSize: 16,
fontWeight: "600",
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
submitButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
// Date Picker Modal Styles
datePickerModal: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
datePickerContent: {
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: 20,
},
datePickerHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
datePickerTitle: {
fontSize: 16,
fontWeight: "600",
color: colors.text,
},
datePickerClose: {
fontSize: 20,
color: colors.text,
},
datePickerIOS: {
height: 200,
marginTop: 20,
},
datePickerButton: {
marginHorizontal: 20,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
marginTop: 20,
},
datePickerButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});
export default CreateOrUpdateShip;

View File

@@ -0,0 +1,522 @@
import { queryShipsImage } from "@/controller/DeviceController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useGroup } from "@/state/use-group";
import { usePort } from "@/state/use-ports";
import { useShipTypes } from "@/state/use-ship-types";
import { Ionicons } from "@expo/vector-icons";
import { fromByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
interface ShipCardProps {
ship: Model.Ship;
onPress?: () => void;
}
export default function ShipCard({ ship, onPress }: ShipCardProps) {
const { colors } = useThemeContext();
const { ports, getPorts } = usePort();
const { shipTypes, getShipTypes } = useShipTypes();
const [shipImage, setShipImage] = useState<string | null>(null);
const { groups, getUserGroups, getChildrenOfGroups, childrenOfGroups } =
useGroup();
useEffect(() => {
if (ports === null) {
getPorts();
}
}, [ports, getPorts]);
useEffect(() => {
if (!shipTypes || shipTypes.length === 0) {
getShipTypes();
}
}, [shipTypes, getShipTypes]);
useEffect(() => {
if (groups === null) {
getUserGroups();
}
}, [groups, getUserGroups]);
useEffect(() => {
if (groups && ship.ship_group_id) {
const groupId = groups?.groups?.[0]?.id || "";
// childrenOfGroups is initialised as null in the store; check for null to fetch once
if (groupId && childrenOfGroups == null) {
getChildrenOfGroups(groupId);
}
}
}, [groups, childrenOfGroups, getChildrenOfGroups]);
// Themed styles
useEffect(() => {
let mounted = true;
const loadShipImage = async () => {
try {
const resp = await queryShipsImage(ship.id || "");
const contentType = resp.headers["content-type"] || "image/jpeg";
const uint8 = new Uint8Array(resp.data); // ArrayBuffer -> Uint8Array
const base64 = fromByteArray(uint8); // base64-js
const uri = `data:${contentType};base64,${base64}`;
if (!mounted) return;
// assign received value to state if present; adapt to actual resp shape as needed
setShipImage(uri);
} catch (error) {
// console.log("Error when get image: ", error);
}
};
loadShipImage();
return () => {
mounted = false;
};
}, [ship]);
const themedStyles = {
card: {
backgroundColor: colors.card,
shadowColor: colors.text,
},
title: {
color: colors.text,
},
subtitle: {
color: colors.textSecondary,
},
label: {
color: colors.textSecondary,
},
value: {
color: colors.text,
},
divider: {
backgroundColor: colors.separator,
},
badge: {
backgroundColor: colors.primary + "15",
borderColor: colors.primary,
},
badgeText: {
color: colors.primary,
},
infoBox: {
backgroundColor: colors.primary + "10",
},
infoIcon: {
color: colors.primary,
},
};
// ============ IMAGE VARIANT ============
if (shipImage) {
return (
<TouchableOpacity
style={[styles.imageCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Image Section */}
<View style={styles.imageContainer}>
{shipImage ? (
<Image
source={{ uri: shipImage }}
style={styles.shipImage}
resizeMode="cover"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<Ionicons name="boat" size={48} color={colors.textSecondary} />
</View>
)}
{/* Ship Type Badge */}
{shipTypes && (
<View style={styles.typeBadge}>
<Ionicons name="boat-outline" size={14} color="#fff" />
<Text style={styles.typeBadgeText}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
)}
</View>
{/* Info Section */}
<View style={styles.imageCardContent}>
{/* Title & Registration */}
<Text style={[styles.imageCardTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<View style={styles.regRow}>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
{childrenOfGroups && (
<View style={styles.locationRow}>
<Ionicons
name="location"
size={14}
color={colors.textSecondary}
/>
<Text style={[styles.locationText, themedStyles.subtitle]}>
{childrenOfGroups.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"}
</Text>
</View>
)}
</View>
{/* Info Grid */}
<View style={styles.imageInfoGrid}>
<InfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="flash"
label="Engine Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="navigate"
label="Home Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)
?.name || "-"
: "-"
}
themedStyles={themedStyles}
/>
</View>
</View>
</TouchableOpacity>
);
}
// ============ COMPACT VARIANT ============
return (
<TouchableOpacity
style={[styles.compactCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Header */}
<View style={styles.compactHeader}>
<View style={[styles.shipIcon, themedStyles.infoBox]}>
<Ionicons name="boat" size={24} color={colors.primary} />
</View>
<View style={styles.compactHeaderText}>
<Text style={[styles.compactTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<Text style={[styles.compactSubtitle, themedStyles.subtitle]}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
</View>
{/* Info Grid */}
<View style={styles.compactInfoGrid}>
<CompactInfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="flash"
label="Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="navigate"
label="Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)?.name ||
"-"
: "-"
}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="location"
label="Province"
value={
childrenOfGroups?.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"
}
themedStyles={themedStyles}
/>
</View>
{/* Footer - IMO & MMSI */}
{(ship.imo_number || ship.mmsi_number) && (
<>
<View style={[styles.divider, themedStyles.divider]} />
<View style={styles.footerInfo}>
{ship.imo_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
IMO Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.imo_number}
</Text>
</View>
)}
{ship.mmsi_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
MMSI Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.mmsi_number}
</Text>
</View>
)}
</View>
</>
)}
</TouchableOpacity>
);
}
// ============ SUB-COMPONENTS ============
interface InfoBoxProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
value: string;
themedStyles: any;
}
function InfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={styles.infoBox}>
<Ionicons name={icon} size={18} color={themedStyles.infoIcon.color} />
<Text style={[styles.infoLabel, themedStyles.label]}>{label}</Text>
<Text style={[styles.infoValue, themedStyles.value]}>{value}</Text>
</View>
);
}
function CompactInfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={[styles.compactInfoBox, themedStyles.infoBox]}>
<Ionicons name={icon} size={16} color={themedStyles.infoIcon.color} />
<View style={styles.compactInfoText}>
<Text style={[styles.compactInfoLabel, themedStyles.label]}>
{label}
</Text>
<Text style={[styles.compactInfoValue, themedStyles.value]}>
{value}
</Text>
</View>
</View>
);
}
// ============ STYLES ============
const styles = StyleSheet.create({
// === IMAGE VARIANT ===
imageCard: {
borderRadius: 16,
overflow: "hidden",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginVertical: 8,
marginHorizontal: 16,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
},
imageContainer: {
height: 180,
position: "relative",
},
shipImage: {
width: "100%",
height: "100%",
},
imagePlaceholder: {
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
},
typeBadge: {
position: "absolute",
top: 12,
left: 12,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(59, 130, 246, 0.9)",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
gap: 6,
},
typeBadgeText: {
color: "#fff",
fontSize: 12,
fontWeight: "600",
},
imageCardContent: {
padding: 16,
},
imageCardTitle: {
fontSize: 18,
fontWeight: "700",
marginBottom: 8,
},
regRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 16,
},
regBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
borderWidth: 1,
},
regBadgeText: {
fontSize: 12,
fontWeight: "600",
},
locationRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
locationText: {
fontSize: 13,
},
imageInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
},
infoBox: {
width: "47%",
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 8,
},
infoLabel: {
fontSize: 12,
},
infoValue: {
fontSize: 14,
fontWeight: "600",
marginLeft: "auto",
},
// === COMPACT VARIANT ===
compactCard: {
borderRadius: 16,
padding: 16,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
marginVertical: 8,
marginHorizontal: 16,
},
compactHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
},
shipIcon: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
compactHeaderText: {
flex: 1,
marginLeft: 12,
},
compactTitle: {
fontSize: 16,
fontWeight: "700",
},
compactSubtitle: {
fontSize: 13,
marginTop: 2,
},
compactInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 10,
},
compactInfoBox: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
gap: 8,
minWidth: "47%",
flexGrow: 1,
},
compactInfoText: {
flex: 1,
},
compactInfoLabel: {
fontSize: 11,
},
compactInfoValue: {
fontSize: 14,
fontWeight: "600",
},
divider: {
height: 1,
marginVertical: 12,
},
footerInfo: {
gap: 6,
},
footerRow: {
flexDirection: "row",
justifyContent: "space-between",
},
footerLabel: {
fontSize: 13,
},
footerValue: {
fontSize: 13,
fontWeight: "500",
},
});

View File

@@ -0,0 +1,145 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import {
queryCreateShip,
queryUpdateShip,
} from "@/controller/DeviceController";
import { useTheme } from "@/hooks/use-theme-context";
import { showSuccessToast } from "@/services/toast_service";
import { useShip } from "@/state/use-ship";
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native";
import CreateOrUpdateShip from "./ship_components/CreateOrUpdateShip";
import ShipCard from "./ship_components/ShipCard";
interface ShipUpdateData {
ship_id: string;
body: Model.ShipBodyRequest;
}
export default function ShipsScreen() {
const { ships, getShip } = useShip();
const { colors } = useTheme();
const [ship, setShip] = useState<ShipUpdateData | null>(null);
const [showUpdateShip, setShowUpdateShip] = useState<boolean>(false);
const [isCreateShip, setIsCreateShip] = useState<boolean>(false);
useEffect(() => {
if (ships === null) {
getShip();
}
}, [ships]);
const handleClickShip = async (ship: Model.Ship) => {
const shipBodyRequest: Model.ShipBodyRequest = {
name: ship.name,
reg_number: ship.reg_number,
imo_number: ship.imo_number,
mmsi_number: ship.mmsi_number,
thing_id: ship.thing_id,
ship_type: ship.ship_type,
owner_id: ship.owner_id,
home_port: ship.home_port,
ship_length: ship.ship_length,
ship_power: ship.ship_power,
ship_group_id: ship.ship_group_id,
fishing_license_number: ship.fishing_license_number,
fishing_license_expiry_date: ship.fishing_license_expiry_date,
};
setShip({ ship_id: ship.id!, body: shipBodyRequest });
setIsCreateShip(false); // Đảm bảo là mode update khi edit
setShowUpdateShip(true);
};
const handleSubmitForm = async (body: Model.ShipBodyRequest) => {
try {
let resp;
if (isCreateShip) {
resp = await queryCreateShip(body);
if (resp.status === 201) {
showSuccessToast("Thêm tàu mới thành công");
}
} else {
resp = await queryUpdateShip(ship!.ship_id, body);
if (resp.status === 200) {
showSuccessToast("Cập nhật thông tin tàu thành công");
}
}
setShowUpdateShip(false);
await getShip();
} catch (error) {
console.error("Error when update/create Ship: ", error);
}
};
const handleCreateNewShip = () => {
setShip(null);
setIsCreateShip(true);
setShowUpdateShip(true);
};
return (
<>
<ScrollView>
<ThemedView style={styles.container}>
{ships?.map((ship) => (
<ShipCard
key={ship.id}
ship={ship}
onPress={() => handleClickShip(ship)}
/>
))}
{/* Thêm khoảng trống ở cuối để không bị FAB che */}
<View style={styles.bottomPadding} />
</ThemedView>
</ScrollView>
{/* Floating Action Button */}
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={handleCreateNewShip}
>
<ThemedText style={styles.fabText}>+</ThemedText>
</TouchableOpacity>
<CreateOrUpdateShip
isOpen={showUpdateShip}
initialValue={ship?.body || undefined}
type={isCreateShip ? "create" : "update"}
onClose={() => setShowUpdateShip(false)}
onSubmit={handleSubmitForm}
/>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: 20, // Khoảng trống cho FAB
},
bottomPadding: {
height: 50, // Thêm khoảng trống ở cuối
},
fab: {
position: "absolute",
bottom: 30,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
fabText: {
fontSize: 24,
fontWeight: "bold",
color: "#ffffff",
lineHeight: 24,
},
});

View File

@@ -1,5 +1,6 @@
import { AlarmData } from "@/app/(tabs)"; import { AlarmData } from "@/app/(tabs)";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { useAppTheme } from "@/hooks/use-app-theme";
import { formatTimestamp } from "@/services/time_service"; import { formatTimestamp } from "@/services/time_service";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useCallback } from "react"; import { useCallback } from "react";
@@ -14,61 +15,66 @@ interface AlarmCardProps {
} }
// ============ Config ============ // ============ Config ============
const ALARM_CONFIG: Record< const getAlarmConfig = (isDark: boolean) => ({
AlarmType,
{
icon: keyof typeof Ionicons.glyphMap;
label: string;
bgColor: string;
borderColor: string;
iconBgColor: string;
iconColor: string;
labelColor: string;
}
> = {
entered: { entered: {
icon: "warning", icon: "warning" as keyof typeof Ionicons.glyphMap,
label: "Xâm nhập", label: "Xâm nhập",
bgColor: "bg-red-50", bgColor: isDark ? "rgba(220, 38, 38, 0.15)" : "#FEF2F2",
borderColor: "border-red-200", borderColor: isDark ? "rgba(220, 38, 38, 0.3)" : "#FECACA",
iconBgColor: "bg-red-100", iconBgColor: isDark ? "rgba(220, 38, 38, 0.25)" : "#FEE2E2",
iconColor: "#DC2626", iconColor: isDark ? "#F87171" : "#DC2626",
labelColor: "text-red-600", labelColor: isDark ? "#F87171" : "#DC2626",
}, },
approaching: { approaching: {
icon: "alert-circle", icon: "alert-circle" as keyof typeof Ionicons.glyphMap,
label: "Tiếp cận", label: "Tiếp cận",
bgColor: "bg-amber-50", bgColor: isDark ? "rgba(217, 119, 6, 0.15)" : "#FFFBEB",
borderColor: "border-amber-200", borderColor: isDark ? "rgba(217, 119, 6, 0.3)" : "#FDE68A",
iconBgColor: "bg-amber-100", iconBgColor: isDark ? "rgba(217, 119, 6, 0.25)" : "#FEF3C7",
iconColor: "#D97706", iconColor: isDark ? "#FBBF24" : "#D97706",
labelColor: "text-amber-600", labelColor: isDark ? "#FBBF24" : "#D97706",
}, },
fishing: { fishing: {
icon: "fish", icon: "fish" as keyof typeof Ionicons.glyphMap,
label: "Đánh bắt", label: "Đánh bắt",
bgColor: "bg-orange-50", bgColor: isDark ? "rgba(234, 88, 12, 0.15)" : "#FFF7ED",
borderColor: "border-orange-200", borderColor: isDark ? "rgba(234, 88, 12, 0.3)" : "#FED7AA",
iconBgColor: "bg-orange-100", iconBgColor: isDark ? "rgba(234, 88, 12, 0.25)" : "#FFEDD5",
iconColor: "#EA580C", iconColor: isDark ? "#FB923C" : "#EA580C",
labelColor: "text-orange-600", labelColor: isDark ? "#FB923C" : "#EA580C",
}, },
}; });
// ============ AlarmCard Component ============ // ============ AlarmCard Component ============
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => { const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
const config = ALARM_CONFIG[alarm.type]; const { colors, utils } = useAppTheme();
const isDark = utils.isDark;
const alarmConfig = getAlarmConfig(isDark);
const config = alarmConfig[alarm.type];
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={onPress} onPress={onPress}
activeOpacity={0.7} activeOpacity={0.7}
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`} style={{
borderRadius: 16,
padding: 16,
backgroundColor: config.bgColor,
borderWidth: 1,
borderColor: config.borderColor,
}}
> >
<View className="flex-row items-start gap-3"> <View className="flex-row items-start gap-3">
{/* Icon Container */} {/* Icon Container */}
<View <View
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`} style={{
width: 48,
height: 48,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
backgroundColor: config.iconBgColor,
}}
> >
<Ionicons name={config.icon} size={24} color={config.iconColor} /> <Ionicons name={config.icon} size={24} color={config.iconColor} />
</View> </View>
@@ -77,12 +83,31 @@ const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
<View className="flex-1"> <View className="flex-1">
{/* Header: Ship name + Badge */} {/* Header: Ship name + Badge */}
<View className="flex-row items-center justify-between mb-1"> <View className="flex-row items-center justify-between mb-1">
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2"> <ThemedText
style={{
fontSize: 16,
fontWeight: "700",
color: colors.text,
flex: 1,
marginRight: 8,
}}
>
{alarm.ship_name || alarm.thing_id} {alarm.ship_name || alarm.thing_id}
</ThemedText> </ThemedText>
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}> <View
style={{
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: config.iconBgColor,
}}
>
<ThemedText <ThemedText
className={`text-xs font-semibold ${config.labelColor}`} style={{
fontSize: 12,
fontWeight: "600",
color: config.labelColor,
}}
> >
{config.label} {config.label}
</ThemedText> </ThemedText>
@@ -90,15 +115,27 @@ const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
</View> </View>
{/* Zone Info */} {/* Zone Info */}
<ThemedText className="text-xs text-gray-600 mb-2" numberOfLines={2}> <ThemedText
style={{
fontSize: 12,
color: colors.textSecondary,
marginBottom: 8,
}}
numberOfLines={2}
>
{alarm.zone.message || alarm.zone.zone_name} {alarm.zone.message || alarm.zone.zone_name}
</ThemedText> </ThemedText>
{/* Footer: Zone ID + Time */} {/* Footer: Zone ID + Time */}
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-1"> <View className="flex-row items-center gap-1">
<Ionicons name="time-outline" size={20} color="#6B7280" /> <Ionicons name="time-outline" size={20} color={colors.icon} />
<ThemedText className="text-xs text-gray-500"> <ThemedText
style={{
fontSize: 12,
color: colors.textSecondary,
}}
>
{formatTimestamp(alarm.zone.gps_time)} {formatTimestamp(alarm.zone.gps_time)}
</ThemedText> </ThemedText>
</View> </View>

View File

@@ -13,28 +13,31 @@ interface ShipInfoProps {
const ShipInfo = ({ thingMetadata }: ShipInfoProps) => { const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useAppTheme(); const { colors, utils } = useAppTheme();
// Định nghĩa màu sắc theo trạng thái const isDark = utils.isDark;
// Định nghĩa màu sắc theo trạng thái - hỗ trợ cả light và dark theme
const statusConfig = { const statusConfig = {
normal: { normal: {
dotColor: "bg-green-500", dotColor: colors.success,
badgeColor: "bg-green-100", badgeColor: isDark ? "rgba(52, 199, 89, 0.2)" : "#dcfce7",
badgeTextColor: "text-green-700", badgeTextColor: isDark ? "#30D158" : "#15803d",
badgeText: "Bình thường", badgeText: "Bình thường",
}, },
warning: { warning: {
dotColor: "bg-yellow-500", dotColor: colors.warning,
badgeColor: "bg-yellow-100", badgeColor: isDark ? "rgba(255, 102, 0, 0.2)" : "#fef3c7",
badgeTextColor: "text-yellow-700", badgeTextColor: isDark ? "#ff9500" : "#b45309",
badgeText: "Cảnh báo", badgeText: "Cảnh báo",
}, },
danger: { danger: {
dotColor: "bg-red-500", dotColor: colors.error,
badgeColor: "bg-red-100", badgeColor: isDark ? "rgba(255, 69, 58, 0.2)" : "#fee2e2",
badgeTextColor: "text-red-700", badgeTextColor: isDark ? "#FF453A" : "#b91c1c",
badgeText: "Nguy hiểm", badgeText: "Nguy hiểm",
}, },
}; };
const getThingStatus = () => { const getThingStatus = () => {
switch (thingMetadata?.state_level) { switch (thingMetadata?.state_level) {
case STATUS_NORMAL: case STATUS_NORMAL:
@@ -67,57 +70,82 @@ const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
<View className="flex-row items-center justify-between mb-3"> <View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
{/* Status dot */} {/* Status dot */}
<View className={`h-3 w-3 rounded-full ${currentStatus.dotColor}`} /> <View
style={{
height: 12,
width: 12,
borderRadius: 6,
backgroundColor: currentStatus.dotColor,
}}
/>
{/* Tên tàu */} {/* Tên tàu */}
<Text className="text-lg font-semibold text-gray-900"> <Text
style={{
fontSize: 18,
fontWeight: "600",
color: colors.text,
}}
>
{thingMetadata?.ship_name} {thingMetadata?.ship_name}
</Text> </Text>
</View> </View>
{/* Badge trạng thái */} {/* Badge trạng thái */}
<View className={`px-3 py-1 rounded-full ${currentStatus.badgeColor}`}> <View
style={{
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: currentStatus.badgeColor,
}}
>
<Text <Text
className={`text-md font-medium ${currentStatus.badgeTextColor}`} style={{
fontSize: 14,
fontWeight: "500",
color: currentStatus.badgeTextColor,
}}
> >
{currentStatus.badgeText} {currentStatus.badgeText}
</Text> </Text>
</View> </View>
</View> </View>
{/* Mã tàu */} {/* Tốc độ và hướng */}
{/* <Text className="text-base text-gray-600 mb-2">{shipCode}</Text> */}
<View className="flex-row items-center justify-between gap-2 mb-3"> <View className="flex-row items-center justify-between gap-2 mb-3">
<View className="flex-row items-center gap-2 w-2/3"> <View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="speedometer-outline" size={16} color="#6B7280" /> <Ionicons name="speedometer-outline" size={16} color={colors.icon} />
<Text className="text-md text-gray-600"> <Text style={{ fontSize: 14, color: colors.textSecondary }}>
{kmhToKnot(gpsData.s || 0)} {t("home.speed_units")} {kmhToKnot(gpsData.s || 0)} {t("home.speed_units")}
</Text> </Text>
</View> </View>
<View className="flex-row items-start gap-2 w-1/3 "> <View className="flex-row items-start gap-2 w-1/3 ">
<Ionicons name="compass-outline" size={16} color="#6B7280" /> <Ionicons name="compass-outline" size={16} color={colors.icon} />
<Text className="text-md text-gray-600">{gpsData.h}°</Text> <Text style={{ fontSize: 14, color: colors.textSecondary }}>
{gpsData.h}°
</Text>
</View> </View>
</View> </View>
{/* Tọa độ */} {/* Tọa độ */}
<View className="flex-row items-center justify-between gap-2 mb-2"> <View className="flex-row items-center justify-between gap-2 mb-2">
<View className="flex-row items-center gap-2 w-2/3"> <View className="flex-row items-center gap-2 w-2/3">
<Ionicons name="location-outline" size={16} color="#6B7280" /> <Ionicons name="location-outline" size={16} color={colors.icon} />
<Text className="text-md text-gray-600"> <Text style={{ fontSize: 14, color: colors.textSecondary }}>
{convertToDMS(gpsData.lat || 0, true)}, {convertToDMS(gpsData.lat || 0, true)},
{convertToDMS(gpsData.lon || 0, false)} {convertToDMS(gpsData.lon || 0, false)}
</Text> </Text>
</View> </View>
<View className=" flex-row items-start gap-2 w-1/3 "> <View className=" flex-row items-start gap-2 w-1/3 ">
<Ionicons name="time-outline" size={16} color="#6B7280" /> <Ionicons name="time-outline" size={16} color={colors.icon} />
<Text className="text-md text-gray-600"> <Text style={{ fontSize: 14, color: colors.textSecondary }}>
{formatRelativeTime(gpsData.t)} {formatRelativeTime(gpsData.t)}
</Text> </Text>
</View> </View>
</View> </View>
{/* <View className="flex">
<Description title="Trạng thái" description={thingMetadata?.state} /> {/* Trạng thái */}
</View> */}
{thingMetadata?.state !== "" && ( {thingMetadata?.state !== "" && (
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}> <ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}>

View File

@@ -1,249 +0,0 @@
import {
queryDeleteSos,
queryGetSos,
querySendSosMessage,
} from "@/controller/DeviceController";
import { useI18n } from "@/hooks/use-i18n";
import { showErrorToast } from "@/services/toast_service";
import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";
import IconButton from "../IconButton";
import Select from "../Select";
import Modal from "../ui/modal";
import { useThemeColor } from "@/hooks/use-theme-color";
const SosButton = () => {
const [sosData, setSosData] = useState<Model.SosResponse | null>();
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
null
);
const [customMessage, setCustomMessage] = useState("");
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { t } = useI18n();
// Theme colors
const textColor = useThemeColor({}, 'text');
const borderColor = useThemeColor({}, 'border');
const errorColor = useThemeColor({}, 'error');
const backgroundColor = useThemeColor({}, 'background');
// Dynamic styles
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
const sosOptions = [
...sosMessage.map((msg) => ({
ma: msg.ma,
moTa: msg.moTa,
label: msg.moTa,
value: msg.ma,
})),
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
];
const getSosData = async () => {
try {
const response = await queryGetSos();
// console.log("SoS ResponseL: ", response);
setSosData(response.data);
} catch (error) {
console.error("Failed to fetch SOS data:", error);
}
};
useEffect(() => {
getSosData();
}, []);
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
if (selectedSosMessage === 999 && customMessage.trim() === "") {
newErrors.customMessage = t("home.sos.statusRequired");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleConfirmSos = async () => {
if (!validateForm()) {
console.log("Form chưa validate");
return; // Không đóng modal nếu validate fail
}
let messageToSend = "";
if (selectedSosMessage === 999) {
messageToSend = customMessage.trim();
} else {
const selectedOption = sosOptions.find(
(opt) => opt.ma === selectedSosMessage
);
messageToSend = selectedOption ? selectedOption.moTa : "";
}
// Gửi dữ liệu đi
await sendSosMessage(messageToSend);
// Đóng modal và reset form sau khi gửi thành công
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
};
const handleClickButton = async (isActive: boolean) => {
console.log("Is Active: ", isActive);
if (isActive) {
const resp = await queryDeleteSos();
if (resp.status === 200) {
await getSosData();
}
} else {
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
setShowConfirmSosDialog(true);
}
};
const sendSosMessage = async (message: string) => {
try {
const resp = await querySendSosMessage(message);
if (resp.status === 200) {
await getSosData();
}
} catch (error) {
console.error("Error when send sos: ", error);
showErrorToast(t("home.sos.sendError"));
}
};
return (
<>
<IconButton
icon={<MaterialIcons name="warning" size={20} color="white" />}
type="danger"
size="middle"
onPress={() => handleClickButton(sosData?.active || false)}
style={{ borderRadius: 20 }}
>
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
</IconButton>
<Modal
open={showConfirmSosDialog}
onCancel={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
okText={t("home.sos.confirm")}
cancelText={t("home.sos.cancel")}
title={t("home.sos.title")}
centered
onOk={handleConfirmSos}
>
{/* Select Nội dung SOS */}
<View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.content")}</Text>
<Select
value={selectedSosMessage ?? undefined}
options={sosOptions}
placeholder={t("home.sos.selectReason")}
onChange={(value) => {
setSelectedSosMessage(value as number);
// Clear custom message nếu chọn khác lý do
if (value !== 999) {
setCustomMessage("");
}
// Clear error if exists
if (errors.sosMessage) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.sosMessage;
return newErrors;
});
}
}}
showSearch={false}
style={[errors.sosMessage ? styles.errorBorder : undefined]}
/>
{errors.sosMessage && (
<Text style={styles.errorText}>{errors.sosMessage}</Text>
)}
</View>
{/* Input Custom Message nếu chọn "Khác" */}
{selectedSosMessage === 999 && (
<View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
<TextInput
style={[
styles.input,
errors.customMessage ? styles.errorInput : {},
]}
placeholder={t("home.sos.enterStatus")}
placeholderTextColor={textColor + '99'} // Add transparency
value={customMessage}
onChangeText={(text) => {
setCustomMessage(text);
if (text.trim() !== "") {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.customMessage;
return newErrors;
});
}
}}
multiline
numberOfLines={4}
/>
{errors.customMessage && (
<Text style={styles.errorText}>{errors.customMessage}</Text>
)}
</View>
)}
</Modal>
</>
);
};
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
formGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginBottom: 8,
color: textColor,
},
errorBorder: {
borderColor: errorColor,
},
input: {
borderWidth: 1,
borderColor: borderColor,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 14,
color: textColor,
backgroundColor: backgroundColor,
textAlignVertical: "top",
},
errorInput: {
borderColor: errorColor,
},
errorText: {
color: errorColor,
fontSize: 12,
marginTop: 4,
},
});
export default SosButton;

View File

@@ -72,7 +72,12 @@ api.interceptors.response.use(
statusText || statusText ||
"Unknown error"; "Unknown error";
// Không hiển thị toast cho status 400 (validation errors) và 404 (not found)
// 404 được xử lý riêng bởi component (ví dụ: search crew không tìm thấy)
if (status !== 400 && status !== 404) {
showErrorToast(`Lỗi ${status}: ${errMsg}`); showErrorToast(`Lỗi ${status}: ${errMsg}`);
}
if (status === 401) { if (status === 401) {
handle401(); handle401();
} }

View File

@@ -1,5 +1,7 @@
export const TOKEN = "token"; export const TOKEN = "token";
export const DOMAIN = "domain"; export const DOMAIN = "domain";
export const UID = "user-id";
export const ROLE = "role";
export const MAP_TRACKPOINTS_ID = "ship-trackpoints"; export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
export const MAP_POLYLINE_BAN = "ban-polyline"; export const MAP_POLYLINE_BAN = "ban-polyline";
export const MAP_POLYGON_BAN = "ban-polygon"; export const MAP_POLYGON_BAN = "ban-polygon";
@@ -36,14 +38,13 @@ export const STATUS_SOS = 3;
// API Path Constants // API Path Constants
export const API_PATH_LOGIN = "/api/tokens"; 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_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_PATH_SHIP_INFO = "/api/sgw/shipinfo";
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist"; export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
export const API_GET_LAYER_INFO = "/api/sgw/geojson"; export const API_GET_LAYER_INFO = "/api/sgw/geojson";
export const API_GET_TRIP = "/api/sgw/trip"; export const API_GET_TRIP = "/api/sgw/trip";
export const API_POST_TRIPSLIST = "api/sgw/tripslist"; 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_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
export const API_HAUL_HANDLE = "/api/sgw/fishingLog"; export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
export const API_GET_GPS = "/api/sgw/gps"; export const API_GET_GPS = "/api/sgw/gps";
@@ -55,3 +56,17 @@ export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types"; export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup"; export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";
export const API_GET_LAST_TRIP = "/api/sgw/trips/last"; export const API_GET_LAST_TRIP = "/api/sgw/trips/last";
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";
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";

View File

@@ -0,0 +1,15 @@
import { api } from "@/config";
import { API_GET_ALARM, API_MANAGER_ALARM } from "@/constants";
export async function queryAlarms(payload: Model.AlarmPayload) {
return await api.get<Model.AlarmResponse>(API_GET_ALARM, {
params: payload,
});
}
export async function queryConfirmAlarm(body: Model.AlarmConfirmRequest) {
return await api.post(API_MANAGER_ALARM, body);
}
export async function queryrUnconfirmAlarm(body: Model.AlarmConfirmRequest) {
return await api.delete(API_MANAGER_ALARM, { data: body });
}

View File

@@ -1,6 +1,10 @@
import { api } from "@/config"; import { api } from "@/config";
import { API_PATH_LOGIN } from "@/constants"; import { API_PATH_GET_PROFILE, API_PATH_LOGIN } from "@/constants";
export async function queryLogin(body: Model.LoginRequestBody) { export async function queryLogin(body: Model.LoginRequestBody) {
return api.post<Model.LoginResponse>(API_PATH_LOGIN, body); return api.post<Model.LoginResponse>(API_PATH_LOGIN, body);
} }
export async function queryProfile() {
return api.get<Model.ProfileResponse>(API_PATH_GET_PROFILE);
}

View File

@@ -0,0 +1,80 @@
import { api } from "@/config";
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<Model.TripCrewPerson>(`${API_SEARCH_CREW}/${personal_id}`);
}
export async function updateCrewInfo(
personalId: string,
body: Model.UpdateCrewAPIRequest
) {
return api.put(`${API_CREW}/${personalId}`, body);
}
export async function queryCrewImage(personal_id: string) {
return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, {
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",
},
});
}

View File

@@ -1,43 +1,11 @@
import { api } from "@/config"; import { api } from "@/config";
import { import {
API_GET_ALARMS, API_GET_ALL_SHIP,
API_GET_GPS, API_GET_PHOTO,
API_GET_SHIP_GROUPS, API_GET_SHIP_GROUPS,
API_GET_SHIP_TYPES, API_GET_SHIP_TYPES,
API_PATH_ENTITIES,
API_PATH_SEARCH_THINGS, API_PATH_SEARCH_THINGS,
API_PATH_SHIP_TRACK_POINTS,
API_SOS,
} from "@/constants"; } from "@/constants";
import { transformEntityResponse } from "@/utils/tranform";
export async function queryGpsData() {
return api.get<Model.GPSResponse>(API_GET_GPS);
}
export async function queryAlarm() {
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
}
export async function queryTrackPoints() {
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
}
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
return response.data.map(transformEntityResponse);
}
export async function queryGetSos() {
return await api.get<Model.SosResponse>(API_SOS);
}
export async function queryDeleteSos() {
return await api.delete<Model.SosResponse>(API_SOS);
}
export async function querySendSosMessage(message: string) {
return await api.put<Model.SosRequest>(API_SOS, { message });
}
export async function querySearchThings(body: Model.SearchThingBody) { export async function querySearchThings(body: Model.SearchThingBody) {
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body); return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
@@ -50,3 +18,23 @@ export async function queryShipTypes() {
export async function queryShipGroups() { export async function queryShipGroups() {
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS); return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
} }
export async function queryAllShips() {
return await api.get<Model.ShipResponse>(API_GET_ALL_SHIP);
}
export async function queryShipsImage(ship_id: string) {
return await api.get(`${API_GET_PHOTO}/ship/${ship_id}/main`, {
responseType: "arraybuffer",
});
}
export async function queryCreateShip(body: Model.ShipBodyRequest) {
return await api.post(`${API_GET_ALL_SHIP}`, body);
}
export async function queryUpdateShip(
shipId: string,
body: Model.ShipBodyRequest
) {
return await api.put(`${API_GET_ALL_SHIP}/${shipId}`, body);
}

View File

@@ -0,0 +1,25 @@
import { api } from "@/config";
import { UID } from "@/constants";
import { getStorageItem } from "@/utils/storage";
export async function queryUserGroup() {
const user_id = await getStorageItem(UID);
return api.get<Model.GroupResponse>(`/api/members/${user_id}/groups`);
}
export async function queryChilrentOfGroups(
group_id: string,
level: number = 5,
isTree: boolean = false
) {
// ensure proper query param values when not provided by caller
const lvl = typeof level === "number" ? level : 5;
const tree = !!isTree;
const params = {
level: lvl,
tree: tree,
};
return api.get<Model.GroupResponse>(`/api/groups/${group_id}/children`, {
params,
});
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/config";
import { API_GET_ALL_PORT } from "@/constants";
export async function queryPorts(body?: Model.SearchThingBody) {
return api.post<Model.PortResponse>(API_GET_ALL_PORT, body);
}

View File

@@ -6,12 +6,22 @@ import {
API_UPDATE_FISHING_LOGS, API_UPDATE_FISHING_LOGS,
API_UPDATE_TRIP_STATUS, API_UPDATE_TRIP_STATUS,
API_GET_LAST_TRIP, API_GET_LAST_TRIP,
API_POST_TRIP,
API_PUT_TRIP,
API_TRIP_CREW,
API_GET_TRIP_BY_ID,
API_TRIP_APPROVE_REQUEST,
API_TRIP_CANCEL_REQUEST,
} from "@/constants"; } from "@/constants";
export async function queryTrip() { export async function queryTrip() {
return api.get<Model.Trip>(API_GET_TRIP); return api.get<Model.Trip>(API_GET_TRIP);
} }
export async function queryTripById(tripId: string) {
return api.get<Model.Trip>(`${API_GET_TRIP_BY_ID}/${tripId}`);
}
export async function queryLastTrip(thingId: string) { export async function queryLastTrip(thingId: string) {
return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`); return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`);
} }
@@ -31,3 +41,19 @@ export async function queryUpdateFishingLogs(body: Model.FishingLog) {
export async function queryTripsList(body: Model.TripListBody) { export async function queryTripsList(body: Model.TripListBody) {
return api.post(API_POST_TRIPSLIST, body); return api.post(API_POST_TRIPSLIST, body);
} }
export async function createTrip(thingId: string, body: Model.TripAPIBody) {
return api.post<Model.Trip>(`${API_POST_TRIP}/${thingId}`, body);
}
export async function updateTrip(tripId: string, body: Model.TripAPIBody) {
return api.put<Model.Trip>(`${API_PUT_TRIP}/${tripId}`, body);
}
export async function tripApproveRequest(tripId: string) {
return api.put<Model.Trip>(`${API_TRIP_APPROVE_REQUEST}/${tripId}`);
}
export async function tripCancelRequest(tripId: string) {
return api.put<Model.Trip>(`${API_TRIP_CANCEL_REQUEST}/${tripId}`);
}

View File

@@ -0,0 +1,25 @@
import { api } from "@/config";
import {
API_GET_PHOTO,
API_GET_TRIP_CREW,
API_SEARCH_CREW,
API_TRIP_CREW,
} from "@/constants";
export async function queryTripCrew(tripId: string) {
return api.get<{ trip_crews: Model.TripCrews[] }>(
`${API_GET_TRIP_CREW}/${tripId}`
);
}
export async function newTripCrew(body: Model.NewTripCrewAPIRequest) {
return api.post(API_TRIP_CREW, body);
}
export async function updateTripCrew(body: Model.UpdateTripCrewAPIRequest) {
return api.put(API_TRIP_CREW, body);
}
export async function deleteTripCrew(tripId: string, personalId: string) {
return api.delete(`${API_TRIP_CREW}/${tripId}/${personalId}`);
}

View File

@@ -1,5 +1,17 @@
import * as AlarmController from "./AlarmController";
import * as AuthController from "./AuthController"; import * as AuthController from "./AuthController";
import * as DeviceController from "./DeviceController"; import * as DeviceController from "./DeviceController";
import * as FishController from "./FishController";
import * as MapController from "./MapController"; import * as MapController from "./MapController";
import * as PortController from "./PortController";
import * as TripController from "./TripController"; import * as TripController from "./TripController";
export { AuthController, DeviceController, MapController, TripController };
export {
AlarmController,
AuthController,
DeviceController,
FishController,
MapController,
PortController,
TripController,
};

View File

@@ -9,6 +9,20 @@ declare namespace Model {
token?: string; token?: string;
} }
interface ProfileResponse {
id?: string;
email?: string;
metadata?: ProfileMetadata;
}
interface ProfileMetadata {
frontend_thing_id?: string;
frontend_thing_key?: string;
full_name?: string;
phone_number?: string;
user_type?: string;
}
interface GPSResponse { interface GPSResponse {
lat: number; lat: number;
lon: number; lon: number;
@@ -17,41 +31,6 @@ declare namespace Model {
fishing: boolean; fishing: boolean;
t: number; t: number;
} }
interface Alarm {
name: string;
t: number; // timestamp (epoch seconds)
level: number;
id: string;
}
interface AlarmResponse {
alarms: Alarm[];
level: number;
}
interface ShipTrackPoint {
time: number;
lon: number;
lat: number;
s: number;
h: number;
}
interface EntityResponse {
id: string;
v: number;
vs: string;
t: number;
type: string;
}
interface TransformedEntity {
id: string;
value: number;
valueString: string;
time: number;
type: string;
}
// Banzones
// Banzone // Banzone
interface Zone { interface Zone {
id?: string; id?: string;
@@ -175,6 +154,37 @@ declare namespace Model {
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;
} }
// TripCrew Request Body
interface NewTripCrewAPIRequest {
trip_id: string;
personal_id: string;
role: "captain" | "crew";
}
interface UpdateTripCrewAPIRequest {
trip_id: string;
personal_id: string;
role: "captain" | "crew";
note: string;
}
interface NewCrewAPIRequest {
personal_id: string;
name: string;
phone: string;
email: string;
birth_date: Date;
note: string;
address: string;
}
interface UpdateCrewAPIRequest {
name: string;
phone: string;
email: string;
birth_date: Date;
//note: string;
address: string;
}
// Chi phí chuyến đi // Chi phí chuyến đi
interface TripCost { interface TripCost {
type: string; type: string;
@@ -221,6 +231,27 @@ declare namespace Model {
status: number; status: number;
note?: string; note?: string;
} }
// API body interface for creating a new trip
interface TripAPIBody {
name: string;
departure_time: string; // ISO string
departure_port_id: number;
arrival_time: string; // ISO string
arrival_port_id: number;
fishing_ground_codes: number[];
fishing_gears: Array<{
name: string;
number: string;
}>;
trip_cost: Array<{
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}>;
}
//Fish //Fish
interface FishSpeciesResponse { interface FishSpeciesResponse {
id: number; id: number;
@@ -332,4 +363,152 @@ declare namespace Model {
owner_id?: string; owner_id?: string;
description?: string; description?: string;
} }
interface AlarmPayload {
offset: number;
limit: number;
order?: string;
dir?: "asc" | "desc";
name?: string;
level?: number;
thing_id?: string;
confirmed?: boolean;
}
interface AlarmResponse {
total?: number;
limit?: number;
order?: string;
dir?: string;
alarms?: Alarm[];
}
interface Alarm {
name?: string;
time?: number;
level?: number;
id?: string;
confirmed?: boolean;
confirmed_email?: string;
confirmed_time?: number;
confirmed_desc?: string;
thing_id?: string;
thing_name?: string;
thing_type?: ThingType;
}
interface AlarmConfirmRequest {
id: string;
description?: string;
thing_id: string;
time: number;
}
interface ShipBodyRequest {
name?: string;
reg_number?: string;
imo_number?: string;
mmsi_number?: string;
thing_id?: string;
ship_type?: number;
owner_id?: string;
home_port?: number;
ship_length?: number;
ship_power?: number;
ship_group_id?: string;
fishing_license_number?: string;
fishing_license_expiry_date?: Date;
}
interface ShipResponse {
ships?: Ship[];
}
interface Ship {
id?: string;
thing_id?: string;
owner_id?: string;
name?: string;
ship_type?: number;
home_port?: number;
ship_length?: number;
ship_power?: number;
reg_number?: string;
imo_number?: string;
mmsi_number?: string;
fishing_license_number?: string;
fishing_license_expiry_date?: Date;
province_code?: string;
ship_group_id?: string;
created_at?: Date;
updated_at?: Date;
}
interface PortResponse {
total?: number;
offset?: number;
limit?: number;
ports?: Port[];
}
interface Port {
id?: number;
name?: string;
type?: Type;
classification?: Classification;
position_point?: string;
has_origin_confirm?: boolean;
province_code?: string;
updated_at?: Date;
is_deleted?: boolean;
}
enum Classification {
ChưaXácĐịnh = "Chưa xác định",
I = "I",
Ii = "II",
}
enum Type {
Fishing = "fishing",
}
// Groups
interface GroupResponse {
total?: number;
level?: number;
name?: string;
groups?: Group[];
}
interface Group {
id?: string;
name?: string;
owner_id?: string;
description?: string;
metadata?: GroupMetadata;
level?: number;
path?: string;
children?: Child[];
created_at?: Date;
updated_at?: Date;
}
interface Child {
id?: string;
name?: string;
owner_id?: string;
parent_id?: string;
description?: string;
metadata?: GroupMetadata;
level?: number;
path?: string;
children?: Child[];
created_at?: Date;
updated_at?: Date;
}
interface GroupMetadata {
code?: string;
short_name?: string;
}
} }

View File

@@ -3,6 +3,7 @@
"app_name": "Sea Gateway", "app_name": "Sea Gateway",
"footer_text": "Product of Mobifone v1.0", "footer_text": "Product of Mobifone v1.0",
"ok": "OK", "ok": "OK",
"confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"done": "Done", "done": "Done",
"save": "Save", "save": "Save",
@@ -23,7 +24,8 @@
"theme": "Theme", "theme": "Theme",
"theme_light": "Light", "theme_light": "Light",
"theme_dark": "Dark", "theme_dark": "Dark",
"theme_system": "System" "theme_system": "System",
"retry": "Retry"
}, },
"navigation": { "navigation": {
"home": "Monitor", "home": "Monitor",
@@ -144,18 +146,31 @@
"costPerUnit": "Cost", "costPerUnit": "Cost",
"totalCost": "Total Cost", "totalCost": "Total Cost",
"tripDuration": "Trip Duration", "tripDuration": "Trip Duration",
"startDate": "Start", "currentTime": "Current Time",
"endDate": "End", "startDate": "Departure",
"endDate": "Arrival",
"date": "Date",
"time": "Time",
"selectDate": "Select Date", "selectDate": "Select Date",
"selectStartDate": "Select start date", "selectStartDate": "Select departure date",
"selectEndDate": "Select end date", "selectEndDate": "Select arrival date",
"selectStartTime": "Select departure time",
"selectEndTime": "Select arrival time",
"portLabel": "Port", "portLabel": "Port",
"departurePort": "Departure Port", "departurePort": "Departure Port",
"arrivalPort": "Arrival Port", "arrivalPort": "Arrival Port",
"selectPort": "Select port", "selectPort": "Select port",
"searchPort": "Search port...",
"noPortsFound": "No ports found",
"fishingGroundCodes": "Fishing Ground Codes", "fishingGroundCodes": "Fishing Ground Codes",
"fishingGroundCodesHint": "Enter fishing ground codes (comma separated)", "fishingGroundCodesHint": "Enter fishing ground codes (comma separated)",
"fishingGroundCodesPlaceholder": "e.g: 1,2,3", "fishingGroundCodesPlaceholder": "e.g: 1,2,3",
"formSection": {
"basicInfo": "Basic Information",
"schedule": "Schedule & Location",
"equipment": "Fishing Gear",
"costs": "Trip Costs"
},
"autoFill": { "autoFill": {
"title": "Auto-fill data", "title": "Auto-fill data",
"description": "Fill from the ship's last trip", "description": "Fill from the ship's last trip",
@@ -165,6 +180,114 @@
"success": "Data filled from last trip", "success": "Data filled from last trip",
"error": "Unable to fetch trip data", "error": "Unable to fetch trip data",
"noData": "No previous trip data available" "noData": "No previous trip data available"
},
"validation": {
"shipRequired": "Please select a ship before creating the trip",
"datesRequired": "Please select departure and arrival dates",
"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.",
"noCrewErrorTitle": "Cannot Send for Approval",
"noCrewErrorMessage": "This trip has no crew members. Please add crew members before sending for approval.",
"sendApprovalError": "Unable to send approval request. 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.",
"searchHint": "Enter ID number, system will auto-search",
"waitingSearch": "Waiting to search...",
"searching": "Searching...",
"crewFound": "Crew member found, data has been auto-filled",
"crewNotFound": "Crew member not found, please enter information to create new",
"crewAlreadyExists": "This crew member is already in the trip"
}
},
"tripDetail": {
"title": "Trip Details",
"notFound": "Trip information not found",
"basicInfo": "Basic Information",
"shipName": "Ship Name",
"shipCode": "Ship Code",
"departureTime": "Departure Time",
"arrivalTime": "Arrival Time",
"departurePort": "Departure Port",
"arrivalPort": "Arrival Port",
"fishingGrounds": "Fishing Grounds",
"alerts": "Alert List",
"noAlerts": "No alerts",
"unknownAlert": "Unknown alert",
"confirmed": "Confirmed",
"costs": "Trip Costs",
"noCosts": "No costs",
"unknownCost": "Unknown cost",
"totalCost": "Total Cost",
"gears": "Fishing Gear List",
"noGears": "No fishing gear",
"unknownGear": "Unknown gear",
"quantity": "Quantity",
"crew": "Crew List",
"noCrew": "No crew",
"unknownCrew": "Unknown crew member",
"roleCaptain": "Captain",
"roleCrew": "Crew",
"roleEngineer": "Engineer",
"fishingLogs": "Fishing Log List",
"noFishingLogs": "No fishing logs",
"startTime": "Start",
"endTime": "End",
"startLocation": "Start Location",
"haulLocation": "Haul Location",
"catchInfo": "Catch Info",
"species": "species",
"unknownFish": "Unknown fish",
"more": "more species",
"logStatusProcessing": "Processing",
"logStatusSuccess": "Complete",
"logStatusCancelled": "Cancelled",
"logStatusUnknown": "Unknown"
} }
}, },
"trip": { "trip": {

View File

@@ -3,6 +3,7 @@
"app_name": "Hệ thống giám sát tàu cá", "app_name": "Hệ thống giám sát tàu cá",
"footer_text": "Sản phẩm của Mobifone v1.0", "footer_text": "Sản phẩm của Mobifone v1.0",
"ok": "OK", "ok": "OK",
"confirm": "Xác nhận",
"cancel": "Hủy", "cancel": "Hủy",
"done": "Xong", "done": "Xong",
"save": "Lưu", "save": "Lưu",
@@ -23,7 +24,8 @@
"theme": "Giao diện", "theme": "Giao diện",
"theme_light": "Sáng", "theme_light": "Sáng",
"theme_dark": "Tối", "theme_dark": "Tối",
"theme_system": "Hệ thống" "theme_system": "Hệ thống",
"retry": "Thử lại"
}, },
"navigation": { "navigation": {
"home": "Giám sát", "home": "Giám sát",
@@ -144,18 +146,31 @@
"costPerUnit": "Chi phí", "costPerUnit": "Chi phí",
"totalCost": "Tổng chi phí", "totalCost": "Tổng chi phí",
"tripDuration": "Thời gian chuyến đi", "tripDuration": "Thời gian chuyến đi",
"startDate": "Bắt đầu", "currentTime": "Thời gian hiện tại",
"endDate": "Kết thúc", "startDate": "Khởi hành",
"endDate": "Cập bến",
"date": "Ngày",
"time": "Giờ",
"selectDate": "Chọn ngày", "selectDate": "Chọn ngày",
"selectStartDate": "Chọn ngày bắt đầu", "selectStartDate": "Chọn ngày khởi hành",
"selectEndDate": "Chọn ngày kết thúc", "selectEndDate": "Chọn ngày cập bến",
"portLabel": " Cả", "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", "departurePort": "Cảng khởi hành",
"arrivalPort": "Cảng cập bến", "arrivalPort": "Cảng cập bến",
"selectPort": "Chọn cảng", "selectPort": "Chọn cảng",
"searchPort": "Tìm kiếm cảng...",
"noPortsFound": "Không tìm thấy cảng phù hợp",
"fishingGroundCodes": "Ô ngư trường khai thác", "fishingGroundCodes": "Ô ngư trường khai thác",
"fishingGroundCodesHint": "Nhập mã ô ngư trường (cách nhau bằng dấu phẩy)", "fishingGroundCodesHint": "Nhập mã ô ngư trường (cách nhau bằng dấu phẩy)",
"fishingGroundCodesPlaceholder": "Ví dụ: 1,2,3", "fishingGroundCodesPlaceholder": "Ví dụ: 1,2,3",
"formSection": {
"basicInfo": "Thông tin cơ bản",
"schedule": "Lịch trình & Vị trí",
"equipment": "Ngư cụ",
"costs": "Chi phí chuyến đi"
},
"autoFill": { "autoFill": {
"title": "Tự động điền dữ liệu", "title": "Tự động điền dữ liệu",
"description": "Điền từ chuyến đi cuối cùng của tàu", "description": "Điền từ chuyến đi cuối cùng của tàu",
@@ -165,6 +180,114 @@
"success": "Đã điền dữ liệu từ chuyến đi cuối cùng", "success": "Đã điền dữ liệu từ chuyến đi cuối cùng",
"error": "Không thể lấy dữ liệu chuyến đi", "error": "Không thể lấy dữ liệu chuyến đi",
"noData": "Không có dữ liệu chuyến đi trước đó" "noData": "Không có dữ liệu chuyến đi trước đó"
},
"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",
"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.",
"noCrewErrorTitle": "Không thể gửi phê duyệt",
"noCrewErrorMessage": "Chuyến đi chưa có thuyền viên. Vui lòng thêm thuyền viên trước khi gửi phê duyệt.",
"sendApprovalError": "Không thể gửi yêu cầu phê duyệt. 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.",
"searchHint": "Nhập số định danh, hệ thống sẽ tự động tìm kiếm",
"waitingSearch": "Đang chờ tìm kiếm...",
"searching": "Đang tìm kiếm...",
"crewFound": "Đã tìm thấy thuyền viên, dữ liệu đã được điền tự động",
"crewNotFound": "Không tìm thấy thuyền viên, vui lòng nhập thông tin để tạo mới",
"crewAlreadyExists": "Thuyền viên này đã có trong chuyến đ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",
"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",
"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",
"logStatusProcessing": "Đang đánh bắt",
"logStatusSuccess": "Đã kết thúc",
"logStatusCancelled": "Đã hủy",
"logStatusUnknown": "Không xác định"
} }
}, },
"trip": { "trip": {

38
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"base64-js": "^1.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
@@ -27,7 +28,7 @@
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7", "expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
@@ -59,6 +60,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
@@ -6554,9 +6556,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
@@ -8545,21 +8547,25 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image": { "node_modules/expo-image-loader": {
"version": "3.0.10", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.10.tgz", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
"integrity": "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ==", "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"expo": "*", "expo": "*"
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
} }
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
} }
}, },
"node_modules/expo-keep-awake": { "node_modules/expo-keep-awake": {

View File

@@ -23,6 +23,7 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"base64-js": "^1.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
@@ -30,7 +31,7 @@
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7", "expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
@@ -62,6 +63,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",

View File

@@ -1,139 +1,20 @@
import { import {
AUTO_REFRESH_INTERVAL, AUTO_REFRESH_INTERVAL,
EVENT_ALARM_DATA,
EVENT_BANZONE_DATA, EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA,
EVENT_GPS_DATA,
EVENT_SEARCH_THINGS, EVENT_SEARCH_THINGS,
EVENT_TRACK_POINTS_DATA,
} from "@/constants"; } from "@/constants";
import { import { querySearchThings } from "@/controller/DeviceController";
queryAlarm,
queryEntities,
queryGpsData,
querySearchThings,
queryTrackPoints,
} from "@/controller/DeviceController";
import { queryBanzones } from "@/controller/MapController"; import { queryBanzones } from "@/controller/MapController";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
const intervals: { const intervals: {
gps: ReturnType<typeof setInterval> | null;
alarm: ReturnType<typeof setInterval> | null;
entities: ReturnType<typeof setInterval> | null;
trackPoints: ReturnType<typeof setInterval> | null;
banzones: ReturnType<typeof setInterval> | null; banzones: ReturnType<typeof setInterval> | null;
searchThings: ReturnType<typeof setInterval> | null; searchThings: ReturnType<typeof setInterval> | null;
} = { } = {
gps: null,
alarm: null,
entities: null,
trackPoints: null,
banzones: null, banzones: null,
searchThings: null, searchThings: null,
}; };
export function getGpsEventBus() {
if (intervals.gps) return;
// console.log("Starting GPS poller");
const getGpsData = async () => {
try {
// console.log("GPS: fetching data...");
const resp = await queryGpsData();
if (resp && resp.data) {
// console.log("GPS: emitting data", resp.data);
eventBus.emit(EVENT_GPS_DATA, resp.data);
} else {
console.log("GPS: no data returned");
}
} catch (err) {
console.error("GPS: fetch error", err);
}
};
// Run immediately once, then schedule
getGpsData();
intervals.gps = setInterval(() => {
getGpsData();
}, AUTO_REFRESH_INTERVAL);
}
export function getAlarmEventBus() {
if (intervals.alarm) return;
// console.log("Goi ham get Alarm");
const getAlarmData = async () => {
try {
// console.log("Alarm: fetching data...");
const resp = await queryAlarm();
if (resp && resp.data) {
// console.log(
// "Alarm: emitting data",
// resp.data?.alarms?.length ?? resp.data
// );
eventBus.emit(EVENT_ALARM_DATA, resp.data);
} else {
console.log("Alarm: no data returned");
}
} catch (err) {
console.error("Alarm: fetch error", err);
}
};
getAlarmData();
intervals.alarm = setInterval(() => {
getAlarmData();
}, AUTO_REFRESH_INTERVAL);
}
export function getEntitiesEventBus() {
if (intervals.entities) return;
// console.log("Goi ham get Entities");
const getEntitiesData = async () => {
try {
// console.log("Entities: fetching data...");
const resp = await queryEntities();
if (resp && resp.length > 0) {
// console.log("Entities: emitting", resp.length);
eventBus.emit(EVENT_ENTITY_DATA, resp);
} else {
console.log("Entities: no data returned");
}
} catch (err) {
console.error("Entities: fetch error", err);
}
};
getEntitiesData();
intervals.entities = setInterval(() => {
getEntitiesData();
}, AUTO_REFRESH_INTERVAL);
}
export function getTrackPointsEventBus() {
if (intervals.trackPoints) return;
// console.log("Goi ham get Track Points");
const getTrackPointsData = async () => {
try {
// console.log("TrackPoints: fetching data...");
const resp = await queryTrackPoints();
if (resp && resp.data && resp.data.length > 0) {
// console.log("TrackPoints: emitting", resp.data.length);
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
} else {
console.log("TrackPoints: no data returned");
}
} catch (err) {
console.error("TrackPoints: fetch error", err);
}
};
getTrackPointsData();
intervals.trackPoints = setInterval(() => {
getTrackPointsData();
}, AUTO_REFRESH_INTERVAL);
}
export function getBanzonesEventBus() { export function getBanzonesEventBus() {
if (intervals.banzones) return; if (intervals.banzones) return;
const getBanzonesData = async () => { const getBanzonesData = async () => {
@@ -199,9 +80,5 @@ export function stopEvents() {
} }
export function startEvents() { export function startEvents() {
getGpsEventBus();
getAlarmEventBus();
getEntitiesEventBus();
getTrackPointsEventBus();
getBanzonesEventBus(); getBanzonesEventBus();
} }

41
state/use-group.ts Normal file
View File

@@ -0,0 +1,41 @@
import {
queryChilrentOfGroups,
queryUserGroup,
} from "@/controller/GroupController";
import { create } from "zustand";
type Groups = {
groups: Model.GroupResponse | null;
childrenOfGroups?: Model.GroupResponse | null;
getChildrenOfGroups: (group_id: string) => Promise<void>;
getUserGroups: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useGroup = create<Groups>((set) => ({
groups: null,
childrenOfGroups: null,
getUserGroups: async () => {
try {
const response = await queryUserGroup();
set({ groups: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ groups: null });
}
},
getChildrenOfGroups: async (group_id: string) => {
try {
set({ loading: true });
const response = await queryChilrentOfGroups(group_id);
set({ childrenOfGroups: response.data, loading: false });
} catch (error) {
console.error("Error when fetching children of groups: ", error);
set({ error: "Failed to fetch children of groups", loading: false });
set({ childrenOfGroups: null });
}
},
error: null,
}));

32
state/use-ports.ts Normal file
View File

@@ -0,0 +1,32 @@
import { queryPorts } from "@/controller/PortController";
import { create } from "zustand";
type Ports = {
ports: Model.PortResponse | null;
getPorts: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const usePort = create<Ports>((set) => ({
ports: null,
getPorts: async (body?: Model.SearchThingBody) => {
try {
if (body === undefined) {
body = {
offset: 0,
limit: 50,
dir: "asc",
order: "id",
};
}
const response = await queryPorts(body);
set({ ports: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ ports: null });
}
},
error: null,
}));

24
state/use-ship-groups.ts Normal file
View File

@@ -0,0 +1,24 @@
import { queryShipGroups } from "@/controller/DeviceController";
import { create } from "zustand";
type ShipGroups = {
shipGroups: Model.ShipGroup[] | null;
getShipGroups: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useShipGroups = create<ShipGroups>((set) => ({
shipGroups: null,
getShipGroups: async () => {
try {
const response = await queryShipGroups();
set({ shipGroups: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ shipGroups: null });
}
},
error: null,
}));

24
state/use-ship.ts Normal file
View File

@@ -0,0 +1,24 @@
import { queryAllShips } from "@/controller/DeviceController";
import { create } from "zustand";
type Ship = {
ships: Model.Ship[] | null;
getShip: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useShip = create<Ship>((set) => ({
ships: null,
getShip: async () => {
try {
const response = await queryAllShips();
set({ ships: response.data?.ships, loading: false });
} catch (error) {
console.error("Error when fetch Ship: ", error);
set({ error: "Failed to fetch Ship data", loading: false });
set({ ships: null });
}
},
error: null,
}));

View File

@@ -1,3 +1,4 @@
import { ROLE, UID } from "@/constants";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
export async function setStorageItem( export async function setStorageItem(
@@ -28,3 +29,20 @@ export async function removeStorageItem(key: string): Promise<void> {
console.error("Error removing storage item:", error); console.error("Error removing storage item:", error);
} }
} }
export async function clearUserStorage() {
try {
await AsyncStorage.removeItem(UID);
await AsyncStorage.removeItem(ROLE);
} catch (error) {
console.error("Error with clear user Storage: ", error);
}
}
export async function addUserStorage(userId: string, role: string) {
try {
setStorageItem(UID, userId);
setStorageItem(ROLE, role);
} catch (error) {
console.error("Error with set user Storage: ", error);
}
}

View File

@@ -0,0 +1,94 @@
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));
}
/**
* Extract province codes from groups
* @param groups - Model.GroupResponse containing groups with metadata.code
* @returns Array of province codes
*/
export function getProvinceCodesFromGroups(
groups: Model.GroupResponse | null | undefined
): string[] {
if (!groups?.groups) return [];
return groups.groups
.map((group) => group.metadata?.code)
.filter((code): code is string => !!code);
}
/**
* Filter ports by province codes extracted from groups
* @param ports - Model.PortResponse containing all ports
* @param groups - Model.GroupResponse to extract province_code from metadata.code
* @returns Filtered ports that match the province codes from groups
*/
export function filterPortsByProvinceCode(
ports: Model.PortResponse | null | undefined,
groups: Model.GroupResponse | null | undefined
): Model.Port[] {
if (!ports?.ports) return [];
if (!groups?.groups) return ports.ports; // Return all ports if no groups
// Extract province codes from groups
const provinceCodes = getProvinceCodesFromGroups(groups);
// If no province codes found, return all ports
if (provinceCodes.length === 0) return ports.ports;
// Filter ports by province codes
return ports.ports.filter(
(port) => port.province_code && provinceCodes.includes(port.province_code)
);
}