Compare commits

..

9 Commits

49 changed files with 6732 additions and 1265 deletions

View File

@@ -2,15 +2,14 @@ import { Tabs, useSegments } from "expo-router";
import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { queryProfile } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n";
import { useColorScheme } from "@/hooks/use-theme-context";
import { useThemeContext } from "@/hooks/use-theme-context";
import { addUserStorage } from "@/utils/storage";
import { useEffect, useRef } from "react";
export default function TabLayout() {
const colorScheme = useColorScheme();
const { colors, colorScheme } = useThemeContext();
const segments = useSegments() as string[];
const prev = useRef<string | null>(null);
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
@@ -51,9 +50,18 @@ export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
tabBarActiveTintColor: colors.tint,
headerShown: false,
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

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Platform,
StyleSheet,
@@ -10,21 +11,25 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard";
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 { useTripsList } from "@/state/use-tripslist";
import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
export default function diary() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
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>({
status: null,
startDate: null,
@@ -36,6 +41,7 @@ export default function diary() {
const [allTrips, setAllTrips] = useState<any[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const isInitialLoad = useRef(true);
const flatListRef = useRef<FlatList>(null);
@@ -50,11 +56,21 @@ export default function diary() {
},
};
// Gọi API things
const { getThings } = useThings();
// Gọi API things nếu chưa có dữ liệu
const { things, getThings } = useThings();
useEffect(() => {
getThings(payloadThings);
}, []);
if (!things) {
getThings(payloadThings);
}
}, [things, getThings]);
// Gọi API ships nếu chưa có dữ liệu
const { ships, getShip } = useShip();
useEffect(() => {
if (!ships) {
getShip();
}
}, [ships, getShip]);
// State cho payload trips
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
@@ -77,11 +93,10 @@ export default function diary() {
const { tripsList, getTripsList, loading } = useTripsList();
// console.log("Payload trips:", payloadTrips);
// Gọi API trips lần đầu
useEffect(() => {
isInitialLoad.current = true;
if (!isInitialLoad.current) return;
isInitialLoad.current = false;
setAllTrips([]);
setHasMore(true);
getTripsList(payloadTrips);
@@ -155,7 +170,11 @@ export default function diary() {
// Hàm load more data khi scroll đến cuối
const handleLoadMore = useCallback(() => {
if (isLoadingMore || !hasMore) {
// Không load more nếu:
// - Đang loading (ban đầu hoặc loadMore)
// - Không còn data để load
// - Chưa có data nào (tránh FlatList tự trigger khi list rỗng)
if (isLoadingMore || loading || !hasMore || allTrips.length === 0) {
return;
}
@@ -167,37 +186,152 @@ export default function diary() {
};
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
}, [isLoadingMore, hasMore, payloadTrips]);
const handleTripPress = (tripId: string) => {
// TODO: Navigate to trip detail
console.log("Trip pressed:", tripId);
};
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
const handleViewTrip = (tripId: string) => {
console.log("View trip:", tripId);
// TODO: Navigate to trip detail view
// Navigate to trip detail page - chỉ truyền tripId
router.push({
pathname: "/trip-detail",
params: { tripId },
});
};
const handleEditTrip = (tripId: string) => {
console.log("Edit trip:", tripId);
// TODO: Navigate to trip edit screen
// Find the trip from allTrips
const tripToEdit = allTrips.find((trip) => trip.id === tripId);
if (tripToEdit) {
setEditingTrip(tripToEdit);
setShowTripFormModal(true);
}
};
const handleViewTeam = (tripId: string) => {
console.log("View team:", tripId);
// TODO: Navigate to team management
const trip = allTrips.find((t) => t.id === tripId);
if (trip) {
router.push({
pathname: "/trip-crew",
params: {
tripId: trip.id,
tripName: trip.name || "",
tripStatus: String(trip.trip_status ?? ""), // trip_status là số
},
});
}
};
const handleSendTrip = (tripId: string) => {
console.log("Send trip:", tripId);
// TODO: Send trip for approval
};
const handleSendTrip = useCallback(
async (tripId: string) => {
try {
// Import dynamically để tránh circular dependency
const { tripApproveRequest } = await import(
"@/controller/TripController"
);
const { queryTripCrew } = await import(
"@/controller/TripCrewController"
);
const handleDeleteTrip = (tripId: string) => {
console.log("Delete trip:", tripId);
// TODO: Show confirmation dialog and delete trip
};
// Kiểm tra xem có thuyền viên không trước khi gửi phê duyệt
const crewResponse = await queryTripCrew(tripId);
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(() => {
@@ -218,6 +352,22 @@ export default function diary() {
}, 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
const themedStyles = {
safeArea: {
@@ -242,7 +392,6 @@ export default function diary() {
({ item }: { item: any }) => (
<TripCard
trip={item}
onPress={() => handleTripPress(item.id)}
onView={() => handleViewTrip(item.id)}
onEdit={() => handleEditTrip(item.id)}
onTeam={() => handleViewTeam(item.id)}
@@ -250,7 +399,13 @@ export default function diary() {
onDelete={() => handleDeleteTrip(item.id)}
/>
),
[]
[
handleViewTrip,
handleEditTrip,
handleViewTeam,
handleSendTrip,
handleDeleteTrip,
]
);
// Key extractor cho FlatList
@@ -291,10 +446,17 @@ export default function diary() {
};
return (
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
<SafeAreaView
style={[styles.safeArea, themedStyles.safeArea]}
edges={["top"]}
>
<View style={styles.container}>
{/* 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 */}
<View style={styles.actionRow}>
@@ -309,7 +471,7 @@ export default function diary() {
/>
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={() => setShowAddTripModal(true)}
onPress={() => setShowTripFormModal(true)}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color="#FFFFFF" />
@@ -332,6 +494,8 @@ export default function diary() {
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={handleRefresh}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
removeClippedSubviews={true}
@@ -348,11 +512,16 @@ export default function diary() {
onApply={handleApplyFilters}
/>
{/* Add Trip Modal */}
<AddTripModal
visible={showAddTripModal}
onClose={() => setShowAddTripModal(false)}
{/* Add/Edit Trip Modal */}
<TripFormModal
visible={showTripFormModal}
onClose={() => {
setShowTripFormModal(false);
setEditingTrip(null);
}}
onSuccess={handleTripAddSuccess}
mode={editingTrip ? "edit" : "add"}
tripData={editingTrip || undefined}
/>
</SafeAreaView>
);
@@ -370,25 +539,23 @@ const styles = StyleSheet.create({
fontSize: 28,
fontWeight: "700",
lineHeight: 36,
marginBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
actionRow: {
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: 12,
marginBottom: 12,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 20,
marginBottom: 10,
},
actionRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 12,
},
countText: {
@@ -405,7 +572,7 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 8,
height: 40,
borderRadius: 8,
gap: 6,
},

View File

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

View File

@@ -84,7 +84,12 @@ const WarningScreen = () => {
});
const slice = resp.data?.alarms ?? [];
setAlarms((prev) => (append ? [...prev, ...slice] : slice));
// Sort alarms by level descending (higher level first: SOS > Danger > Warning > Info)
// When appending, we need to sort the entire combined list, not just the new slice
setAlarms((prev) => {
const combined = append ? [...prev, ...slice] : slice;
return combined.sort((a, b) => (b.level ?? 0) - (a.level ?? 0));
});
setOffset(nextOffset);
setHasMore(nextOffset + PAGE_SIZE < resp.data?.total!);
} catch (error) {

View File

@@ -2,14 +2,14 @@ import {
DarkTheme,
DefaultTheme,
ThemeProvider,
Theme,
} from "@react-navigation/native";
import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { View, StyleSheet } from "react-native";
import "react-native-reanimated";
// import Toast from "react-native-toast-message";
// import { toastConfig } from "@/config";
import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth";
import "@/global.css";
@@ -18,50 +18,101 @@ import {
ThemeProvider as AppThemeProvider,
useThemeContext,
} from "@/hooks/use-theme-context";
import { Colors } from "@/constants/theme";
import Toast from "react-native-toast-message";
import "../global.css";
function AppContent() {
const router = useRouter();
const { colorScheme } = useThemeContext();
console.log("Color Scheme: ", colorScheme);
const { colorScheme, colors } = useThemeContext();
useEffect(() => {
setRouterInstance(router);
}, [router]);
// Create custom navigation theme that uses our app colors
// This ensures the navigation container background matches our theme
const navigationTheme: Theme = useMemo(() => {
const baseTheme = colorScheme === "dark" ? DarkTheme : DefaultTheme;
return {
...baseTheme,
colors: {
...baseTheme.colors,
background: colors.background,
card: colors.card,
text: colors.text,
border: colors.border,
primary: colors.primary,
},
};
}, [colorScheme, colors]);
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack
screenOptions={{ headerShown: false }}
initialRouteName="auth/login"
>
<Stack.Screen
name="auth/login"
options={{
title: "Login",
// 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
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"
>
<Stack.Screen
name="auth/login"
options={{
title: "Login",
headerShown: false,
}}
/>
<Stack.Screen
name="(tabs)"
options={{
title: "Home",
headerShown: false,
}}
/>
<Stack.Screen
name="(tabs)"
options={{
title: "Home",
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style="auto" />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider>
<Stack.Screen
name="trip-detail"
options={{
title: "Trip Detail",
headerShown: false,
}}
/>
<Stack.Screen
name="trip-crew"
options={{
title: "Trip Crew",
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default function RootLayout() {
return (
<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,3 +1,4 @@
import { useAppTheme } from "@/hooks/use-app-theme";
import { Ionicons } from "@expo/vector-icons";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import React, { useEffect, useState } from "react";
@@ -36,6 +37,7 @@ export default function DraggablePanel({
}: DraggablePanelProps) {
const { height: screenHeight } = useWindowDimensions();
const bottomOffset = useBottomTabBarHeight();
const { colors } = useAppTheme();
const minHeight = screenHeight * minHeightPct;
const maxHeight = screenHeight * maxHeightPct;
@@ -141,7 +143,12 @@ export default function DraggablePanel({
return (
<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 */}
<GestureDetector gesture={panGesture}>
<Pressable onPress={togglePanel} style={styles.header}>
@@ -151,7 +158,7 @@ export default function DraggablePanel({
style={styles.toggleButton}
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>
</Pressable>
</GestureDetector>

View File

@@ -34,40 +34,54 @@ export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
);
// 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) => {
if (level === 3) {
// Danger - Red
return {
level: 3,
icon: "warning" as const,
bgColor: "#fee2e2",
borderColor: "#DC0E0E",
iconColor: "#dc2626",
statusBg: "#dcfce7",
statusText: "#166534",
};
} else if (level === 2) {
// Caution - Yellow/Orange
return {
level: 2,
icon: "alert-circle" as const,
bgColor: "#fef3c7",
borderColor: "#FF6C0C",
iconColor: "#d97706",
statusBg: "#fef08a",
statusText: "#713f12",
};
} else {
// Info - Green
return {
level: 1,
icon: "information-circle" as const,
bgColor: "#fffefe",
borderColor: "#FF937E",
iconColor: "#FF937E",
statusBg: "#dcfce7",
statusText: "#166534",
};
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",
};
}
};
@@ -217,14 +231,16 @@ export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
style={[
styles.statusBadge,
{
backgroundColor: alarm.confirmed ? "#8FD14F" : "#EEEEEE",
backgroundColor: alarm.confirmed
? "#8FD14F"
: colors.surfaceSecondary,
},
]}
>
<Text
style={[
styles.statusText,
{ color: alarm.confirmed ? "#166534" : "black" },
{ color: alarm.confirmed ? "#166534" : colors.text },
]}
>
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
@@ -262,7 +278,10 @@ export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
Nhập ghi chú xác nhận
</Text>
<TextInput
style={[styles.input, { color: colors.text }]}
style={[
styles.input,
{ color: colors.text, borderColor: colors.border },
]}
placeholder="Nhập ghi chú"
placeholderTextColor={colors.textSecondary}
multiline
@@ -272,14 +291,20 @@ export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
/>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
style={[
styles.modalButton,
styles.cancelButton,
{ backgroundColor: colors.surfaceSecondary },
]}
onPress={() => {
setShowModal(false);
setNote("");
}}
disabled={submitting}
>
<Text style={styles.cancelText}>Hủy</Text>
<Text style={[styles.cancelText, { color: colors.text }]}>
Hủy
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useRef, useEffect } from "react";
import {
View,
Text,
@@ -7,6 +7,8 @@ import {
StyleSheet,
Platform,
ScrollView,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import StatusDropdown from "./StatusDropdown";
@@ -71,6 +73,47 @@ export default function FilterModal({
const [endDate, setEndDate] = useState<Date | 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 = () => {
setStatus(null);
setStartDate(null);
@@ -79,8 +122,22 @@ export default function FilterModal({
};
const handleApply = () => {
onApply({ status, startDate, endDate, selectedShip });
onClose();
// 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 });
onClose();
});
};
const hasFilters =
@@ -128,23 +185,26 @@ export default function FilterModal({
return (
<Modal
visible={visible}
animationType="fade"
animationType="none"
transparent
onRequestClose={onClose}
onRequestClose={handleClose}
>
<TouchableOpacity
style={styles.overlay}
activeOpacity={1}
onPress={onClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<TouchableOpacity
style={[styles.modalContainer, themedStyles.modalContainer]}
style={styles.overlayTouchable}
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
onPress={handleClose}
/>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] }
]}
>
{/* 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} />
</TouchableOpacity>
<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>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Animated.View>
</Animated.View>
</Modal>
);
}
@@ -231,6 +291,13 @@ const styles = StyleSheet.create({
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
overlayTouchable: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,

View File

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

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

View File

@@ -20,11 +20,15 @@ interface FishingGear {
interface FishingGearListProps {
items: FishingGear[];
onChange: (items: FishingGear[]) => void;
disabled?: boolean;
hideTitle?: boolean;
}
export default function FishingGearList({
items,
onChange,
disabled = false,
hideTitle = false,
}: FishingGearListProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
@@ -75,9 +79,11 @@ export default function FishingGearList({
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.fishingGearList")}
</Text>
{!hideTitle && (
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.fishingGearList")}
</Text>
)}
{/* Gear Items List */}
{items.map((gear, index) => (
@@ -93,6 +99,7 @@ export default function FishingGearList({
onChangeText={(value) => handleUpdateGear(gear.id, "name", value)}
placeholder={t("diary.gearNamePlaceholder")}
placeholderTextColor={colors.textSecondary}
editable={!disabled}
/>
</View>
@@ -108,38 +115,43 @@ export default function FishingGearList({
placeholder={t("diary.gearNumberPlaceholder")}
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
editable={!disabled}
/>
</View>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateGear(gear)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveGear(gear.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={20} color={colors.error} />
</TouchableOpacity>
</View>
{/* Action Buttons - hide when disabled */}
{!disabled && (
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateGear(gear)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveGear(gear.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={20} color={colors.error} />
</TouchableOpacity>
</View>
)}
</View>
))}
{/* Add Button */}
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddGear}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addFishingGear")}
</Text>
</TouchableOpacity>
{/* Add Button - hide when disabled */}
{!disabled && (
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddGear}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addFishingGear")}
</Text>
</TouchableOpacity>
)}
</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 {
items: TripCost[];
onChange: (items: TripCost[]) => void;
disabled?: boolean;
hideTitle?: boolean;
}
// Predefined cost types
@@ -39,6 +41,8 @@ const COST_TYPES = [
export default function MaterialCostList({
items,
onChange,
disabled = false,
hideTitle = false,
}: MaterialCostListProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
@@ -131,9 +135,11 @@ export default function MaterialCostList({
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.materialCostList")}
</Text>
{!hideTitle && (
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("trip.costTable.title")}
</Text>
)}
{/* Cost Items List */}
{items.map((cost) => (
@@ -146,8 +152,9 @@ export default function MaterialCostList({
</Text>
<TouchableOpacity
style={[styles.dropdown, themedStyles.dropdown]}
onPress={() => setTypeDropdownVisible(cost.id)}
activeOpacity={0.7}
onPress={disabled ? undefined : () => setTypeDropdownVisible(cost.id)}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<Text
style={[
@@ -158,11 +165,13 @@ export default function MaterialCostList({
>
{getTypeLabel(cost.type)}
</Text>
<Ionicons
name="chevron-down"
size={16}
color={colors.textSecondary}
/>
{!disabled && (
<Ionicons
name="chevron-down"
size={16}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
{/* Type Dropdown Modal */}
@@ -222,6 +231,7 @@ export default function MaterialCostList({
placeholder="0"
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
editable={!disabled}
/>
</View>
@@ -238,6 +248,7 @@ export default function MaterialCostList({
}
placeholder={t("diary.unitPlaceholder")}
placeholderTextColor={colors.textSecondary}
editable={!disabled}
/>
</View>
</View>
@@ -261,6 +272,7 @@ export default function MaterialCostList({
placeholder="0"
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
editable={!disabled}
/>
</View>
@@ -276,40 +288,44 @@ export default function MaterialCostList({
</View>
</View>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateMaterial(cost)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveMaterial(cost.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.error}
/>
</TouchableOpacity>
</View>
{/* Action Buttons - hide when disabled */}
{!disabled && (
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateMaterial(cost)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveMaterial(cost.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.error}
/>
</TouchableOpacity>
</View>
)}
</View>
</View>
))}
{/* Add Button */}
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddMaterial}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addMaterialCost")}
</Text>
</TouchableOpacity>
{/* Add Button - hide when disabled */}
{!disabled && (
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddMaterial}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addMaterialCost")}
</Text>
</TouchableOpacity>
)}
</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 {
selectedShipId: string;
onChange: (shipId: string) => void;
disabled?: boolean;
}
export default function ShipSelector({
selectedShipId,
onChange,
disabled = false,
}: ShipSelectorProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
@@ -58,7 +60,10 @@ export default function ShipSelector({
const themedStyles = {
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 },
placeholder: { color: colors.textSecondary },
modalContent: { backgroundColor: colors.card },
@@ -81,8 +86,9 @@ export default function ShipSelector({
</Text>
<TouchableOpacity
style={[styles.selector, themedStyles.selector]}
onPress={() => setIsOpen(true)}
activeOpacity={0.7}
onPress={() => !disabled && setIsOpen(true)}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<Text
style={[
@@ -93,11 +99,13 @@ export default function ShipSelector({
>
{displayValue}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
{!disabled && (
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
<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 {
value: string;
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 { colors } = useThemeContext();
const themedStyles = {
label: { color: colors.text },
input: {
backgroundColor: colors.card,
backgroundColor: disabled ? colors.backgroundSecondary : colors.card,
borderColor: colors.border,
color: colors.text,
},
@@ -38,6 +39,7 @@ export default function TripNameInput({ value, onChange }: TripNameInputProps) {
onChangeText={onChange}
placeholder={t("diary.tripNamePlaceholder")}
placeholderTextColor={colors.textSecondary}
editable={!disabled}
/>
</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,337 +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);
// Temp states to hold the picker value before confirming
const [tempStartDate, setTempStartDate] = useState<Date>(new Date());
const [tempEndDate, setTempEndDate] = useState<Date>(new Date());
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 handleOpenStartPicker = () => {
const today = new Date();
const dateToUse = startDate || today;
// If no date selected, immediately set to today
if (!startDate) {
onStartDateChange(today);
}
// Always set tempStartDate to the date we're using (today if no date was selected)
setTempStartDate(dateToUse);
setShowStartPicker(true);
};
const handleOpenEndPicker = () => {
const today = new Date();
const dateToUse = endDate || today;
// If no date selected, immediately set to today
if (!endDate) {
onEndDateChange(today);
}
// Always set tempEndDate to the date we're using (today if no date was selected)
setTempEndDate(dateToUse);
setShowEndPicker(true);
};
const handleStartDateChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") {
setShowStartPicker(false);
if (event.type === "set" && selectedDate) {
onStartDateChange(selectedDate);
}
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempStartDate(selectedDate);
onStartDateChange(selectedDate);
}
};
const handleEndDateChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") {
setShowEndPicker(false);
if (event.type === "set" && selectedDate) {
onEndDateChange(selectedDate);
}
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempEndDate(selectedDate);
onEndDateChange(selectedDate);
}
};
const handleConfirmStartDate = () => {
setShowStartPicker(false);
};
const handleConfirmEndDate = () => {
setShowEndPicker(false);
};
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={handleOpenStartPicker}
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={handleOpenEndPicker}
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={handleConfirmStartDate}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={tempStartDate}
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={handleConfirmEndDate}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={tempEndDate}
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,416 +0,0 @@
import React, { useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ScrollView,
Alert,
ActivityIndicator,
} 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";
import { createTrip } from "@/controller/TripController";
// 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 AddTripModalProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void; // Callback khi thêm chuyến đi thành công
}
export default function AddTripModal({ visible, onClose, onSuccess }: 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 [isSubmitting, setIsSubmitting] = useState(false);
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 = async () => {
// Validate thingId is required
if (!selectedShipId) {
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
return;
}
// Validate dates are required
if (!startDate || !endDate) {
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return;
}
// Validate trip name is required
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return;
}
// Parse fishing ground codes from comma-separated string to array of numbers
const fishingGroundCodesArray = fishingGroundCodes
.split(",")
.map((code) => parseInt(code.trim()))
.filter((code) => !isNaN(code));
// Format API body
const apiBody: Model.TripAPIBody = {
thing_id: selectedShipId,
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,
})),
};
setIsSubmitting(true);
try {
const response = await createTrip(selectedShipId, apiBody);
if (response.data) {
// Show success alert
Alert.alert(
t("common.success"),
t("diary.createTripSuccess")
);
// Call onSuccess callback
if (onSuccess) {
onSuccess();
}
// Reset form and close modal
handleCancel();
}
} catch (error: any) {
console.error("Error creating trip:", error);
// Log detailed error information for debugging
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response data:", JSON.stringify(error.response.data, null, 2));
}
console.log("Request body was:", JSON.stringify(apiBody, null, 2));
Alert.alert(t("common.error"), t("diary.createTripError"));
} finally {
setIsSubmitting(false);
}
};
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,
isSubmitting && styles.submitButtonDisabled
]}
onPress={handleSubmit}
activeOpacity={0.7}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<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",
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,5 +1,6 @@
import { AlarmData } from "@/app/(tabs)";
import { ThemedText } from "@/components/themed-text";
import { useAppTheme } from "@/hooks/use-app-theme";
import { formatTimestamp } from "@/services/time_service";
import { Ionicons } from "@expo/vector-icons";
import { useCallback } from "react";
@@ -14,61 +15,66 @@ interface AlarmCardProps {
}
// ============ Config ============
const ALARM_CONFIG: Record<
AlarmType,
{
icon: keyof typeof Ionicons.glyphMap;
label: string;
bgColor: string;
borderColor: string;
iconBgColor: string;
iconColor: string;
labelColor: string;
}
> = {
const getAlarmConfig = (isDark: boolean) => ({
entered: {
icon: "warning",
icon: "warning" as keyof typeof Ionicons.glyphMap,
label: "Xâm nhập",
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconBgColor: "bg-red-100",
iconColor: "#DC2626",
labelColor: "text-red-600",
bgColor: isDark ? "rgba(220, 38, 38, 0.15)" : "#FEF2F2",
borderColor: isDark ? "rgba(220, 38, 38, 0.3)" : "#FECACA",
iconBgColor: isDark ? "rgba(220, 38, 38, 0.25)" : "#FEE2E2",
iconColor: isDark ? "#F87171" : "#DC2626",
labelColor: isDark ? "#F87171" : "#DC2626",
},
approaching: {
icon: "alert-circle",
icon: "alert-circle" as keyof typeof Ionicons.glyphMap,
label: "Tiếp cận",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
iconBgColor: "bg-amber-100",
iconColor: "#D97706",
labelColor: "text-amber-600",
bgColor: isDark ? "rgba(217, 119, 6, 0.15)" : "#FFFBEB",
borderColor: isDark ? "rgba(217, 119, 6, 0.3)" : "#FDE68A",
iconBgColor: isDark ? "rgba(217, 119, 6, 0.25)" : "#FEF3C7",
iconColor: isDark ? "#FBBF24" : "#D97706",
labelColor: isDark ? "#FBBF24" : "#D97706",
},
fishing: {
icon: "fish",
icon: "fish" as keyof typeof Ionicons.glyphMap,
label: "Đánh bắt",
bgColor: "bg-orange-50",
borderColor: "border-orange-200",
iconBgColor: "bg-orange-100",
iconColor: "#EA580C",
labelColor: "text-orange-600",
bgColor: isDark ? "rgba(234, 88, 12, 0.15)" : "#FFF7ED",
borderColor: isDark ? "rgba(234, 88, 12, 0.3)" : "#FED7AA",
iconBgColor: isDark ? "rgba(234, 88, 12, 0.25)" : "#FFEDD5",
iconColor: isDark ? "#FB923C" : "#EA580C",
labelColor: isDark ? "#FB923C" : "#EA580C",
},
};
});
// ============ AlarmCard Component ============
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 (
<TouchableOpacity
onPress={onPress}
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">
{/* Icon Container */}
<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} />
</View>
@@ -77,12 +83,31 @@ const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
<View className="flex-1">
{/* Header: Ship name + Badge */}
<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}
</ThemedText>
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 9999,
backgroundColor: config.iconBgColor,
}}
>
<ThemedText
className={`text-xs font-semibold ${config.labelColor}`}
style={{
fontSize: 12,
fontWeight: "600",
color: config.labelColor,
}}
>
{config.label}
</ThemedText>
@@ -90,15 +115,27 @@ const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
</View>
{/* 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}
</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">
<Ionicons name="time-outline" size={20} color={colors.icon} />
<ThemedText
style={{
fontSize: 12,
color: colors.textSecondary,
}}
>
{formatTimestamp(alarm.zone.gps_time)}
</ThemedText>
</View>

View File

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

View File

@@ -24,7 +24,7 @@ const codeMessage = {
// Tạo instance axios với cấu hình cơ bản
const api: AxiosInstance = axios.create({
baseURL: "https://sgw.gms.vn",
baseURL: "https://sgw.gms.vn",
timeout: 20000, // Timeout 20 giây
headers: {
"Content-Type": "application/json",
@@ -72,8 +72,9 @@ api.interceptors.response.use(
statusText ||
"Unknown error";
// Không hiển thị toast cho status 400 (validation errors)
if (status !== 400) {
// 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}`);
}

View File

@@ -40,13 +40,11 @@ export const STATUS_SOS = 3;
export const API_PATH_LOGIN = "/api/tokens";
export const API_PATH_GET_PROFILE = "/api/users/profile";
export const API_PATH_SEARCH_THINGS = "/api/things/search";
export const API_PATH_ENTITIES = "/api/io/entities";
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
export const API_GET_LAYER_INFO = "/api/sgw/geojson";
export const API_GET_TRIP = "/api/sgw/trip";
export const API_POST_TRIPSLIST = "api/sgw/tripslist";
export const API_GET_ALARMS = "/api/io/alarms";
export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
export const API_GET_GPS = "/api/sgw/gps";
@@ -58,9 +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_GROUPS = "/api/sgw/shipsgroup";
export const API_GET_LAST_TRIP = "/api/sgw/trips/last";
export const API_POST_TRIP = "/api/sgw/trips";
export const API_POST_TRIP = "/api/sgw/trips";
export const API_PUT_TRIP = "/api/sgw/trips";
export const API_GET_ALARM = "/api/alarms";
export const API_MANAGER_ALARM = "/api/alarms/confirm";
export const API_GET_ALL_SHIP = "/api/sgw/ships";
export const API_GET_ALL_PORT = "/api/sgw/ports";
export const API_GET_PHOTO = "/api/sgw/photo";
export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";
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,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

@@ -19,10 +19,8 @@ export async function queryShipGroups() {
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
}
export async function queryAllShips(params: Model.SearchThingBody) {
return await api.get<Model.ShipResponse>(API_GET_ALL_SHIP, {
params: params,
});
export async function queryAllShips() {
return await api.get<Model.ShipResponse>(API_GET_ALL_SHIP);
}
export async function queryShipsImage(ship_id: string) {

View File

@@ -7,12 +7,21 @@ import {
API_UPDATE_TRIP_STATUS,
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";
export async function queryTrip() {
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) {
return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`);
}
@@ -36,3 +45,15 @@ export async function queryTripsList(body: Model.TripListBody) {
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

@@ -154,6 +154,37 @@ declare namespace Model {
created_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
interface TripCost {
type: string;
@@ -203,7 +234,6 @@ declare namespace Model {
// API body interface for creating a new trip
interface TripAPIBody {
thing_id?: string;
name: string;
departure_time: string; // ISO string
departure_port_id: number;
@@ -341,6 +371,7 @@ declare namespace Model {
dir?: "asc" | "desc";
name?: string;
level?: number;
thing_id?: string;
confirmed?: boolean;
}

View File

@@ -3,6 +3,7 @@
"app_name": "Sea Gateway",
"footer_text": "Product of Mobifone v1.0",
"ok": "OK",
"confirm": "Confirm",
"cancel": "Cancel",
"done": "Done",
"save": "Save",
@@ -23,7 +24,8 @@
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
"theme_system": "System",
"retry": "Retry"
},
"navigation": {
"home": "Monitor",
@@ -144,18 +146,31 @@
"costPerUnit": "Cost",
"totalCost": "Total Cost",
"tripDuration": "Trip Duration",
"startDate": "Start",
"endDate": "End",
"currentTime": "Current Time",
"startDate": "Departure",
"endDate": "Arrival",
"date": "Date",
"time": "Time",
"selectDate": "Select Date",
"selectStartDate": "Select start date",
"selectEndDate": "Select end date",
"selectStartDate": "Select departure date",
"selectEndDate": "Select arrival date",
"selectStartTime": "Select departure time",
"selectEndTime": "Select arrival time",
"portLabel": "Port",
"departurePort": "Departure Port",
"arrivalPort": "Arrival Port",
"selectPort": "Select port",
"searchPort": "Search port...",
"noPortsFound": "No ports found",
"fishingGroundCodes": "Fishing Ground Codes",
"fishingGroundCodesHint": "Enter fishing ground codes (comma separated)",
"fishingGroundCodesPlaceholder": "e.g: 1,2,3",
"formSection": {
"basicInfo": "Basic Information",
"schedule": "Schedule & Location",
"equipment": "Fishing Gear",
"costs": "Trip Costs"
},
"autoFill": {
"title": "Auto-fill data",
"description": "Fill from the ship's last trip",
@@ -169,10 +184,111 @@
"validation": {
"shipRequired": "Please select a ship before creating the trip",
"datesRequired": "Please select departure and arrival dates",
"tripNameRequired": "Please enter a trip name"
"tripNameRequired": "Please enter a trip name",
"startDateNotInPast": "Departure time cannot be in the past",
"endDateAfterStart": "Arrival time must be after departure time"
},
"createTripSuccess": "Trip created successfully!",
"createTripError": "Unable to create trip. Please try again."
"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": {
"infoTrip": "Trip Information",

View File

@@ -3,6 +3,7 @@
"app_name": "Hệ thống giám sát tàu cá",
"footer_text": "Sản phẩm của Mobifone v1.0",
"ok": "OK",
"confirm": "Xác nhận",
"cancel": "Hủy",
"done": "Xong",
"save": "Lưu",
@@ -23,7 +24,8 @@
"theme": "Giao diện",
"theme_light": "Sáng",
"theme_dark": "Tối",
"theme_system": "Hệ thống"
"theme_system": "Hệ thống",
"retry": "Thử lại"
},
"navigation": {
"home": "Giám sát",
@@ -144,18 +146,31 @@
"costPerUnit": "Chi phí",
"totalCost": "Tổng chi phí",
"tripDuration": "Thời gian chuyến đi",
"startDate": "Bắt đầu",
"endDate": "Kết thúc",
"currentTime": "Thời gian hiện tại",
"startDate": "Khởi hành",
"endDate": "Cập bến",
"date": "Ngày",
"time": "Giờ",
"selectDate": "Chọn ngày",
"selectStartDate": "Chọn ngày bắt đầu",
"selectEndDate": "Chọn ngày kết thúc",
"portLabel": " Cả",
"selectStartDate": "Chọn ngày khởi hành",
"selectEndDate": "Chọn ngày cập bến",
"selectStartTime": "Chọn giờ khởi hành",
"selectEndTime": "Chọn giờ cập bến",
"portLabel": " Cảng",
"departurePort": "Cảng khởi hành",
"arrivalPort": "Cảng cập bến",
"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",
"fishingGroundCodesHint": "Nhập mã ô ngư trường (cách nhau bằng dấu phẩy)",
"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": {
"title": "Tự động điền dữ liệu",
"description": "Điền từ chuyến đi cuối cùng của tàu",
@@ -169,10 +184,111 @@
"validation": {
"shipRequired": "Vui lòng chọn tàu trước khi tạo chuyến đi",
"datesRequired": "Vui lòng chọn ngày khởi hành và ngày kết thúc",
"tripNameRequired": "Vui lòng nhập tên chuyến đi"
"tripNameRequired": "Vui lòng nhập tên chuyến đi",
"startDateNotInPast": "Thời điểm khởi hành không được ở quá khứ",
"endDateAfterStart": "Thời điểm kết thúc phải sau thời điểm khởi hành"
},
"createTripSuccess": "Tạo chuyến đi thành công!",
"createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại."
"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": {
"infoTrip": "Thông Tin Chuyến Đi",

29
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-router": "~6.0.13",
@@ -59,6 +60,7 @@
},
"devDependencies": {
"@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11",
@@ -6554,9 +6556,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==",
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -8545,6 +8547,27 @@
"expo": "*"
}
},
"node_modules/expo-image-loader": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
"integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"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": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz",

View File

@@ -31,6 +31,7 @@
"expo-constants": "~18.0.10",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-router": "~6.0.13",
@@ -62,6 +63,7 @@
},
"devDependencies": {
"@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11",

View File

@@ -12,7 +12,7 @@ export const useShip = create<Ship>((set) => ({
ships: null,
getShip: async () => {
try {
const response = await queryAllShips({});
const response = await queryAllShips();
set({ ships: response.data?.ships, loading: false });
} catch (error) {
console.error("Error when fetch Ship: ", error);

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)
);
}