Compare commits
15 Commits
7c3497d159
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 940e108e75 | |||
| 88bdaeb3a6 | |||
| 499bd76f5e | |||
| bf261e70e4 | |||
| 871360af49 | |||
| 190e44b09e | |||
| 24847504b1 | |||
| 000a4ed856 | |||
| afc6acbfe2 | |||
| 67e9fc22a3 | |||
|
|
12fb7c48ed | ||
|
|
35027a7e23 | ||
|
|
6af6749712 | ||
|
|
3e1c4dcbc5 | ||
|
|
df4318fed4 |
1494
ReactQuery_Axios.md
Normal file
1494
ReactQuery_Axios.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,13 +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;
|
||||
@@ -29,12 +30,38 @@ export default function TabLayout() {
|
||||
}
|
||||
}, [currentSegment]);
|
||||
|
||||
useEffect(() => {
|
||||
const getUserProfile = async () => {
|
||||
try {
|
||||
const resp = await queryProfile();
|
||||
if (resp.data && resp.status === 200) {
|
||||
await addUserStorage(
|
||||
resp.data.id || "",
|
||||
resp.data.metadata?.user_type || ""
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when get Profile: ", error);
|
||||
}
|
||||
};
|
||||
getUserProfile();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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
|
||||
|
||||
@@ -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,7 +41,9 @@ 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);
|
||||
|
||||
// Body call API things (đang fix cứng)
|
||||
const payloadThings: Model.SearchThingBody = {
|
||||
@@ -49,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>({
|
||||
@@ -76,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);
|
||||
@@ -154,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;
|
||||
}
|
||||
|
||||
@@ -166,37 +186,187 @@ 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(() => {
|
||||
// Reset về trang đầu và gọi lại API
|
||||
isInitialLoad.current = true;
|
||||
setAllTrips([]);
|
||||
setHasMore(true);
|
||||
const resetPayload: Model.TripListBody = {
|
||||
...payloadTrips,
|
||||
offset: 0,
|
||||
};
|
||||
setPayloadTrips(resetPayload);
|
||||
getTripsList(resetPayload);
|
||||
|
||||
// Scroll FlatList lên đầu
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}, 100);
|
||||
}, [payloadTrips, getTripsList]);
|
||||
|
||||
// Handle refresh - pull-to-refresh
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
isInitialLoad.current = true;
|
||||
setAllTrips([]);
|
||||
setHasMore(true);
|
||||
const resetPayload: Model.TripListBody = {
|
||||
...payloadTrips,
|
||||
offset: 0,
|
||||
};
|
||||
setPayloadTrips(resetPayload);
|
||||
getTripsList(resetPayload).finally(() => {
|
||||
setRefreshing(false);
|
||||
});
|
||||
}, [payloadTrips, getTripsList]);
|
||||
|
||||
// Dynamic styles based on theme
|
||||
const themedStyles = {
|
||||
@@ -222,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)}
|
||||
@@ -230,7 +399,13 @@ export default function diary() {
|
||||
onDelete={() => handleDeleteTrip(item.id)}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[
|
||||
handleViewTrip,
|
||||
handleEditTrip,
|
||||
handleViewTeam,
|
||||
handleSendTrip,
|
||||
handleDeleteTrip,
|
||||
]
|
||||
);
|
||||
|
||||
// Key extractor cho FlatList
|
||||
@@ -271,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}>
|
||||
@@ -289,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" />
|
||||
@@ -304,6 +486,7 @@ export default function diary() {
|
||||
|
||||
{/* Trip List with FlatList */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={allTrips}
|
||||
renderItem={renderTripItem}
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -311,6 +494,8 @@ export default function diary() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
removeClippedSubviews={true}
|
||||
@@ -327,10 +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>
|
||||
);
|
||||
@@ -348,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: {
|
||||
@@ -383,7 +572,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,35 +1,178 @@
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import DevicesScreen from "@/components/manager/devices";
|
||||
import FleetsScreen from "@/components/manager/fleets";
|
||||
import ShipsScreen from "@/components/manager/ships";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { Colors } from "@/config";
|
||||
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Animated, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function manager() {
|
||||
const { colors, colorScheme } = useTheme();
|
||||
const styles = useMemo(
|
||||
() => createStyles(colors, colorScheme),
|
||||
[colors, colorScheme]
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<"ships" | "devices" | "fleets">(
|
||||
"ships"
|
||||
);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const indicatorTranslate = useRef(new Animated.Value(0)).current;
|
||||
const SEGMENT_COUNT = 3;
|
||||
const indexMap: Record<string, number> = {
|
||||
ships: 0,
|
||||
devices: 1,
|
||||
fleets: 2,
|
||||
};
|
||||
|
||||
const SegmentButton = ({
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onPress?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.segmentButton, active && styles.segmentButtonActive]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText
|
||||
style={[styles.segmentText, active && styles.segmentTextActive]}
|
||||
>
|
||||
{label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (containerWidth <= 0) return;
|
||||
const segmentWidth = containerWidth / SEGMENT_COUNT;
|
||||
const toValue = indexMap[selected] * segmentWidth;
|
||||
Animated.spring(indicatorTranslate, {
|
||||
toValue,
|
||||
useNativeDriver: true,
|
||||
friction: 14,
|
||||
tension: 100,
|
||||
}).start();
|
||||
}, [selected, containerWidth, indicatorTranslate]);
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={["top"]}>
|
||||
<ThemedView style={styles.scrollContent}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.titleText}>Quản lý tàu </Text>
|
||||
<ThemedView style={styles.header}>
|
||||
<View
|
||||
style={styles.segmentContainer}
|
||||
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{/* sliding indicator */}
|
||||
{containerWidth > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{
|
||||
width: Math.max(containerWidth / SEGMENT_COUNT - 8, 0),
|
||||
transform: [{ translateX: indicatorTranslate }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SegmentButton
|
||||
label="Tàu"
|
||||
active={selected === "ships"}
|
||||
onPress={() => setSelected("ships")}
|
||||
/>
|
||||
<SegmentButton
|
||||
label="Thiết bị"
|
||||
active={selected === "devices"}
|
||||
onPress={() => setSelected("devices")}
|
||||
/>
|
||||
<SegmentButton
|
||||
label="Đội tàu"
|
||||
active={selected === "fleets"}
|
||||
onPress={() => setSelected("fleets")}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<View style={styles.contentWrapper}>
|
||||
{selected === "ships" && <ShipsScreen />}
|
||||
{selected === "devices" && <DevicesScreen />}
|
||||
{selected === "fleets" && <FleetsScreen />}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
alignItems: "center",
|
||||
padding: 15,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 32,
|
||||
fontWeight: "700",
|
||||
lineHeight: 40,
|
||||
marginBottom: 30,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
|
||||
StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
header: {
|
||||
width: "100%",
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
segmentContainer: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 10,
|
||||
padding: 4,
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 8,
|
||||
},
|
||||
segmentButtonActive: {
|
||||
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
|
||||
shadowColor: scheme === "dark" ? "transparent" : "#000",
|
||||
shadowOpacity: scheme === "dark" ? 0 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: scheme === "dark" ? 0 : 2,
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: colors.text,
|
||||
},
|
||||
indicator: {
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
|
||||
borderRadius: 8,
|
||||
shadowColor: scheme === "dark" ? "transparent" : "#000",
|
||||
shadowOpacity: scheme === "dark" ? 0 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: scheme === "dark" ? 0 : 2,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// import ButtonCancelTrip from "@/components/ButtonCancelTrip";
|
||||
// import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
|
||||
// import ButtonEndTrip from "@/components/ButtonEndTrip";
|
||||
// import CrewListTable from "@/components/tripInfo/CrewListTable";
|
||||
// import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
|
||||
// import NetListTable from "@/components/tripInfo/NetListTable";
|
||||
// import TripCostTable from "@/components/tripInfo/TripCostTable";
|
||||
// import { useI18n } from "@/hooks/use-i18n";
|
||||
// import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
// import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
// import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
// export default function TripInfoScreen() {
|
||||
// const { t } = useI18n();
|
||||
// const { colors } = useThemeContext();
|
||||
// return (
|
||||
// <SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
|
||||
// <View style={styles.header}>
|
||||
// <Text style={[styles.titleText, { color: colors.text }]}>
|
||||
// {t("trip.infoTrip")}
|
||||
// </Text>
|
||||
// <View style={styles.buttonWrapper}>
|
||||
// <ButtonCreateNewHaulOrTrip />
|
||||
// </View>
|
||||
// </View>
|
||||
// <ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
// <View style={styles.container}>
|
||||
// <TripCostTable />
|
||||
// <FishingToolsTable />
|
||||
// <CrewListTable />
|
||||
// <NetListTable />
|
||||
// <View style={styles.buttonRow}>
|
||||
// <ButtonCancelTrip />
|
||||
// <ButtonEndTrip />
|
||||
// </View>
|
||||
// </View>
|
||||
// </ScrollView>
|
||||
// </SafeAreaView>
|
||||
// );
|
||||
// }
|
||||
|
||||
// const styles = StyleSheet.create({
|
||||
// safeArea: {
|
||||
// flex: 1,
|
||||
// paddingBottom: 5,
|
||||
// },
|
||||
// scrollContent: {
|
||||
// flexGrow: 1,
|
||||
// },
|
||||
// header: {
|
||||
// width: "100%",
|
||||
// paddingHorizontal: 15,
|
||||
// paddingTop: 15,
|
||||
// paddingBottom: 10,
|
||||
// alignItems: "center",
|
||||
// },
|
||||
// buttonWrapper: {
|
||||
// width: "100%",
|
||||
// flexDirection: "row",
|
||||
// justifyContent: "flex-end",
|
||||
// },
|
||||
// container: {
|
||||
// alignItems: "center",
|
||||
// paddingHorizontal: 15,
|
||||
// },
|
||||
// buttonRow: {
|
||||
// flexDirection: "row",
|
||||
// gap: 10,
|
||||
// marginTop: 15,
|
||||
// marginBottom: 15,
|
||||
// },
|
||||
// titleText: {
|
||||
// fontSize: 32,
|
||||
// fontWeight: "700",
|
||||
// lineHeight: 40,
|
||||
// paddingBottom: 10,
|
||||
// fontFamily: Platform.select({
|
||||
// ios: "System",
|
||||
// android: "Roboto",
|
||||
// default: "System",
|
||||
// }),
|
||||
// },
|
||||
// });
|
||||
@@ -1,302 +1,251 @@
|
||||
import { AlarmCard } from "@/components/alarm/AlarmCard";
|
||||
import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { queryAlarms } from "@/controller/AlarmController";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
FlatList,
|
||||
LayoutAnimation,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AlarmData } from ".";
|
||||
|
||||
// ============ Types ============
|
||||
type AlarmType = "approaching" | "entered" | "fishing";
|
||||
const PAGE_SIZE = 2;
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: AlarmData;
|
||||
onPress?: () => void;
|
||||
}
|
||||
const WarningScreen = () => {
|
||||
const [defaultAlarmParams, setDefaultAlarmParams] =
|
||||
useState<Model.AlarmPayload>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
order: "time",
|
||||
dir: "desc",
|
||||
});
|
||||
const [alarms, setAlarms] = useState<Model.Alarm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isShowSearchForm, setIsShowSearchForm] = useState(false);
|
||||
const [formOpacity] = useState(new Animated.Value(0));
|
||||
|
||||
// ============ Config ============
|
||||
const ALARM_CONFIG: Record<
|
||||
AlarmType,
|
||||
{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
labelColor: string;
|
||||
}
|
||||
> = {
|
||||
entered: {
|
||||
icon: "warning",
|
||||
label: "Xâm nhập",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconBgColor: "bg-red-100",
|
||||
iconColor: "#DC2626",
|
||||
labelColor: "text-red-600",
|
||||
},
|
||||
approaching: {
|
||||
icon: "alert-circle",
|
||||
label: "Tiếp cận",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
iconBgColor: "bg-amber-100",
|
||||
iconColor: "#D97706",
|
||||
labelColor: "text-amber-600",
|
||||
},
|
||||
fishing: {
|
||||
icon: "fish",
|
||||
label: "Đánh bắt",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200",
|
||||
iconBgColor: "bg-orange-100",
|
||||
iconColor: "#EA580C",
|
||||
labelColor: "text-orange-600",
|
||||
},
|
||||
};
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
// ============ Helper Functions ============
|
||||
const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
const hasFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(defaultAlarmParams as any)?.name ||
|
||||
((defaultAlarmParams as any)?.level !== undefined &&
|
||||
(defaultAlarmParams as any).level !== 0) ||
|
||||
(defaultAlarmParams as any)?.confirmed !== undefined
|
||||
);
|
||||
}, [defaultAlarmParams]);
|
||||
|
||||
// ============ AlarmCard Component ============
|
||||
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
||||
const config = ALARM_CONFIG[alarm.type];
|
||||
useEffect(() => {
|
||||
getAlarmsData(0, false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||
>
|
||||
<View className="flex-row items-start gap-3">
|
||||
{/* Icon Container */}
|
||||
<View
|
||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
useEffect(() => {
|
||||
if (isShowSearchForm) {
|
||||
// Reset opacity to 0, then animate to 1
|
||||
formOpacity.setValue(0);
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
formOpacity.setValue(0);
|
||||
}
|
||||
}, [isShowSearchForm, formOpacity]);
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
{alarm.ship_name || alarm.thing_id}
|
||||
</ThemedText>
|
||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||
<ThemedText
|
||||
className={`text-xs font-semibold ${config.labelColor}`}
|
||||
>
|
||||
{config.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
const getAlarmsData = async (
|
||||
nextOffset = 0,
|
||||
append = false,
|
||||
paramsOverride?: Model.AlarmPayload
|
||||
) => {
|
||||
try {
|
||||
if (append) setIsLoadingMore(true);
|
||||
else setLoading(true);
|
||||
// console.log("Call alarm with offset: ", nextOffset);
|
||||
const usedParams = paramsOverride ?? defaultAlarmParams;
|
||||
// console.log("params: ", usedParams);
|
||||
|
||||
{/* Zone Info */}
|
||||
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
||||
{alarm.zone.message || alarm.zone.zone_name}
|
||||
</ThemedText>
|
||||
const resp = await queryAlarms({
|
||||
...usedParams,
|
||||
offset: nextOffset,
|
||||
});
|
||||
const slice = resp.data?.alarms ?? [];
|
||||
|
||||
{/* Footer: Zone ID + Time */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||
<ThemedText className="text-xs text-gray-500">
|
||||
{formatTimestamp(alarm.zone.gps_time)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
// 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) {
|
||||
console.error("Cannot get Alarm Data: ", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
AlarmCard.displayName = "AlarmCard";
|
||||
|
||||
// ============ Main Component ============
|
||||
interface WarningScreenProps {
|
||||
alarms?: AlarmData[];
|
||||
}
|
||||
|
||||
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
||||
// Mock data for demo - replace with actual props
|
||||
const sampleAlarms: AlarmData[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
thing_id: "SHIP-001",
|
||||
ship_name: "Ocean Star",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực cấm A1",
|
||||
zone_id: "A1",
|
||||
message: "Tàu đã đi vào vùng cấm A1",
|
||||
alarm_type: 1,
|
||||
lat: 10.12345,
|
||||
lon: 106.12345,
|
||||
s: 12,
|
||||
h: 180,
|
||||
fishing: false,
|
||||
gps_time: 1733389200,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-002",
|
||||
ship_name: "Blue Whale",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng cảnh báo B3",
|
||||
zone_id: "B3",
|
||||
message: "Tàu đang tiếp cận khu vực cấm B3",
|
||||
alarm_type: 2,
|
||||
lat: 9.87654,
|
||||
lon: 105.87654,
|
||||
gps_time: 1733389260,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-003",
|
||||
ship_name: "Sea Dragon",
|
||||
type: "fishing",
|
||||
zone: {
|
||||
zone_type: 3,
|
||||
zone_name: "Vùng cấm đánh bắt C2",
|
||||
zone_id: "C2",
|
||||
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
||||
alarm_type: 3,
|
||||
lat: 11.11223,
|
||||
lon: 107.44556,
|
||||
fishing: true,
|
||||
gps_time: 1733389320,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-004",
|
||||
ship_name: "Red Coral",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực A2",
|
||||
zone_id: "A2",
|
||||
message: "Tàu đã đi sâu vào khu vực A2",
|
||||
alarm_type: 1,
|
||||
gps_time: 1733389380,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-005",
|
||||
ship_name: "Silver Wind",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng B1",
|
||||
zone_id: "B1",
|
||||
message: "Tàu đang tiến gần vào vùng B1",
|
||||
alarm_type: 2,
|
||||
gps_time: 1733389440,
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
||||
|
||||
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
||||
console.log("Alarm pressed:", alarm);
|
||||
// TODO: Navigate to alarm detail or show modal
|
||||
const handleAlarmReload = useCallback((onReload: boolean) => {
|
||||
if (onReload) {
|
||||
getAlarmsData(0, false, undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderAlarmCard = useCallback(
|
||||
({ item }: { item: AlarmData }) => (
|
||||
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
||||
({ item }: { item: Model.Alarm }) => (
|
||||
<AlarmCard alarm={item} onReload={handleAlarmReload} />
|
||||
),
|
||||
[handleAlarmPress]
|
||||
[handleAlarmReload]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||
(item: Model.Alarm, index: number) =>
|
||||
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
const nextOffset = offset + PAGE_SIZE;
|
||||
getAlarmsData(nextOffset, true);
|
||||
}, [isLoadingMore, hasMore, offset]);
|
||||
|
||||
// Count alarms by type
|
||||
const alarmCounts = useMemo(() => {
|
||||
return displayAlarms.reduce((acc, alarm) => {
|
||||
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<AlarmType, number>);
|
||||
}, [displayAlarms]);
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
getAlarmsData(0, false, undefined);
|
||||
}, []);
|
||||
|
||||
const onSearch = useCallback(
|
||||
(values: { name?: string; level?: number; confirmed?: boolean }) => {
|
||||
const mapped = {
|
||||
offset: 0,
|
||||
limit: defaultAlarmParams.limit,
|
||||
order: defaultAlarmParams.order,
|
||||
dir: defaultAlarmParams.dir,
|
||||
...(values.name && { name: values.name }),
|
||||
...(values.level && values.level !== 0 && { level: values.level }),
|
||||
...(values.confirmed !== undefined && { confirmed: values.confirmed }),
|
||||
};
|
||||
|
||||
setDefaultAlarmParams(mapped);
|
||||
// Call getAlarmsData with the mapped params directly so the
|
||||
// request uses the updated params immediately (setState is async)
|
||||
getAlarmsData(0, false, mapped);
|
||||
toggleSearchForm();
|
||||
},
|
||||
[defaultAlarmParams]
|
||||
);
|
||||
|
||||
const toggleSearchForm = useCallback(() => {
|
||||
if (Platform.OS === "ios") {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
}
|
||||
|
||||
if (isShowSearchForm) {
|
||||
// Hide form
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setIsShowSearchForm(false);
|
||||
});
|
||||
} else {
|
||||
// Show form
|
||||
setIsShowSearchForm(true);
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isShowSearchForm, formOpacity]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||
<ThemedView style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
||||
<Ionicons name="warning" size={22} color="#fff" />
|
||||
</View>
|
||||
<View style={styles.headerLeft}>
|
||||
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||
</View>
|
||||
<View className="bg-red-500 px-3 py-1 rounded-full">
|
||||
<ThemedText className="text-white text-sm font-semibold">
|
||||
{displayAlarms.length}
|
||||
</ThemedText>
|
||||
<View style={styles.badgeContainer}>
|
||||
<TouchableOpacity onPress={toggleSearchForm}>
|
||||
<Ionicons
|
||||
size={20}
|
||||
name="filter-outline"
|
||||
color={hasFilters ? colors.primary : colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<View className="flex-row px-4 pb-3 gap-2">
|
||||
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
||||
(type) => {
|
||||
const config = ALARM_CONFIG[type];
|
||||
const count = alarmCounts[type] || 0;
|
||||
return (
|
||||
<View
|
||||
key={type}
|
||||
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={14}
|
||||
color={config.iconColor}
|
||||
/>
|
||||
<ThemedText
|
||||
className={`text-xs font-medium ${config.labelColor}`}
|
||||
>
|
||||
{count}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
{/* Search Form */}
|
||||
{isShowSearchForm && (
|
||||
<Animated.View style={{ opacity: formOpacity, zIndex: 100 }}>
|
||||
<AlarmSearchForm
|
||||
initialValue={{
|
||||
name: defaultAlarmParams.name || "",
|
||||
level: defaultAlarmParams.level || 0,
|
||||
confirmed: defaultAlarmParams.confirmed,
|
||||
}}
|
||||
onSubmit={onSearch}
|
||||
onReset={toggleSearchForm}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Alarm List */}
|
||||
<FlatList
|
||||
data={displayAlarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
/>
|
||||
{alarms.length > 0 ? (
|
||||
<FlatList
|
||||
data={alarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
ListFooterComponent={
|
||||
isLoadingMore ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator size="small" color="#dc2626" />
|
||||
<ThemedText style={styles.footerText}>Đang tải...</ThemedText>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="shield-checkmark" size={48} color="#16a34a" />
|
||||
<ThemedText style={styles.emptyText}>
|
||||
Không có cảnh báo nào
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default WarningScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -311,13 +260,60 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#dc2626",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 26,
|
||||
fontSize: 24,
|
||||
fontWeight: "700",
|
||||
},
|
||||
badgeContainer: {
|
||||
// backgroundColor: "#dc2626",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
badgeText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
footer: {
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
|
||||
111
app/_layout.tsx
111
app/_layout.tsx
@@ -2,14 +2,14 @@ import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
Theme,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import "react-native-reanimated";
|
||||
// import Toast from "react-native-toast-message";
|
||||
|
||||
// import { toastConfig } from "@/config";
|
||||
import { toastConfig } from "@/config";
|
||||
import { setRouterInstance } from "@/config/auth";
|
||||
import "@/global.css";
|
||||
@@ -18,50 +18,101 @@ import {
|
||||
ThemeProvider as AppThemeProvider,
|
||||
useThemeContext,
|
||||
} from "@/hooks/use-theme-context";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import Toast from "react-native-toast-message";
|
||||
import "../global.css";
|
||||
function AppContent() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useThemeContext();
|
||||
console.log("Color Scheme: ", colorScheme);
|
||||
const { colorScheme, colors } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
setRouterInstance(router);
|
||||
}, [router]);
|
||||
|
||||
// Create custom navigation theme that uses our app colors
|
||||
// This ensures the navigation container background matches our theme
|
||||
const navigationTheme: Theme = useMemo(() => {
|
||||
const baseTheme = colorScheme === "dark" ? DarkTheme : DefaultTheme;
|
||||
return {
|
||||
...baseTheme,
|
||||
colors: {
|
||||
...baseTheme.colors,
|
||||
background: colors.background,
|
||||
card: colors.card,
|
||||
text: colors.text,
|
||||
border: colors.border,
|
||||
primary: colors.primary,
|
||||
},
|
||||
};
|
||||
}, [colorScheme, colors]);
|
||||
|
||||
return (
|
||||
<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
358
app/trip-crew.tsx
Normal 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
456
app/trip-detail.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
interface ButtonCancelTripProps {
|
||||
title?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 2, // cho Android
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonCancelTrip;
|
||||
@@ -1,213 +0,0 @@
|
||||
import { queryGpsData } from "@/controller/DeviceController";
|
||||
import {
|
||||
queryStartNewHaul,
|
||||
queryUpdateTripState,
|
||||
} from "@/controller/TripController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import {
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showWarningToast,
|
||||
} from "@/services/toast_service";
|
||||
import { useTrip } from "@/state/use-trip";
|
||||
import { AntDesign } from "@expo/vector-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, View } from "react-native";
|
||||
import IconButton from "./IconButton";
|
||||
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
||||
|
||||
interface StartButtonProps {
|
||||
gpsData?: Model.GPSResponse;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
interface a {
|
||||
fishingLogs?: Model.FishingLogInfo[] | null;
|
||||
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
||||
gpsData,
|
||||
onPress,
|
||||
}) => {
|
||||
const [isStarted, setIsStarted] = useState(false);
|
||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { trip, getTrip } = useTrip();
|
||||
useEffect(() => {
|
||||
getTrip();
|
||||
}, []);
|
||||
|
||||
const checkHaulFinished = () => {
|
||||
return trip?.fishing_logs?.some((h) => h.status === 0);
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (isStarted) {
|
||||
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
||||
{
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("trip.endButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(false);
|
||||
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||
{
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("trip.startButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(true);
|
||||
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTrip = async (state: number, note?: string) => {
|
||||
if (trip?.trip_status !== 2) {
|
||||
showWarningToast(t("trip.alreadyStarted"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await queryUpdateTripState({
|
||||
status: state,
|
||||
note: note || "",
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
showSuccessToast(t("trip.startTripSuccess"));
|
||||
await getTrip();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error stating trip :", error);
|
||||
showErrorToast("");
|
||||
}
|
||||
};
|
||||
|
||||
const createNewHaul = async () => {
|
||||
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
||||
showWarningToast(t("trip.finishCurrentHaul"));
|
||||
return;
|
||||
}
|
||||
if (!gpsData) {
|
||||
const response = await queryGpsData();
|
||||
gpsData = response.data;
|
||||
}
|
||||
try {
|
||||
const body: Model.NewFishingLogRequest = {
|
||||
trip_id: trip?.id || "",
|
||||
start_at: new Date(),
|
||||
start_lat: gpsData.lat,
|
||||
start_lon: gpsData.lon,
|
||||
weather_description: t("trip.weatherDescription"),
|
||||
};
|
||||
|
||||
const resp = await queryStartNewHaul(body);
|
||||
if (resp.status === 200) {
|
||||
showSuccessToast(t("trip.startHaulSuccess"));
|
||||
await getTrip();
|
||||
} else {
|
||||
showErrorToast(t("trip.createHaulFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// showErrorToast(t("trip.createHaulFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
||||
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{trip?.trip_status === 2 ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" />}
|
||||
type="primary"
|
||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||
onPress={async () => handleStartTrip(3)}
|
||||
>
|
||||
{t("trip.startTrip")}
|
||||
</IconButton>
|
||||
) : checkHaulFinished() ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" color={"white"} />}
|
||||
type="primary"
|
||||
style={{ borderRadius: 10 }}
|
||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||
>
|
||||
{t("trip.endHaul")}
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" color={"white"} />}
|
||||
type="primary"
|
||||
style={{ borderRadius: 10 }}
|
||||
onPress={async () => {
|
||||
createNewHaul();
|
||||
}}
|
||||
>
|
||||
{t("trip.startHaul")}
|
||||
</IconButton>
|
||||
)}
|
||||
<CreateOrUpdateHaulModal
|
||||
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
||||
fishingLogIndex={trip?.fishing_logs?.length!}
|
||||
isVisible={isFinishHaulModalOpen}
|
||||
onClose={function (): void {
|
||||
setIsFinishHaulModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#4ecdc4", // màu ngọc lam
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 3, // hiệu ứng nổi trên Android
|
||||
},
|
||||
buttonActive: {
|
||||
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
icon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonCreateNewHaulOrTrip;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
interface ButtonEndTripProps {
|
||||
title?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonEndTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#ed9434", // màu cam sáng
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 28,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 2, // hiệu ứng nổi trên Android
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonEndTrip;
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Colors } from "@/config";
|
||||
import { queryShipGroups } from "@/controller/DeviceController";
|
||||
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
|
||||
import { useShipGroups } from "@/state/use-ship-groups";
|
||||
import { useShipTypes } from "@/state/use-ship-types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -44,8 +44,8 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||
[colors, colorScheme]
|
||||
);
|
||||
const { shipTypes, getShipTypes } = useShipTypes();
|
||||
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
|
||||
const [slideAnim] = useState(new Animated.Value(0));
|
||||
const { shipGroups, getShipGroups } = useShipGroups();
|
||||
|
||||
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
|
||||
defaultValues: {
|
||||
@@ -70,8 +70,10 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||
}, [shipTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
getShipGroups();
|
||||
}, []);
|
||||
if (shipGroups === null) {
|
||||
getShipGroups();
|
||||
}
|
||||
}, [props.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.isOpen) {
|
||||
@@ -107,17 +109,6 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||
}
|
||||
}, [props.initialValues]);
|
||||
|
||||
const getShipGroups = async () => {
|
||||
try {
|
||||
const response = await queryShipGroups();
|
||||
if (response && response.data) {
|
||||
setGroupShips(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching ship groups:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const alarmListLabel = [
|
||||
{
|
||||
label: "Tiếp cận vùng hạn chế",
|
||||
@@ -366,10 +357,12 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||
name="ship_group_id"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
options={groupShips.map((group) => ({
|
||||
label: group.name || "",
|
||||
value: group.id || "",
|
||||
}))}
|
||||
options={
|
||||
shipGroups?.map((group) => ({
|
||||
label: group.name || "",
|
||||
value: group.id || "",
|
||||
})) || []
|
||||
}
|
||||
placeholder="Chọn đội tàu"
|
||||
mode="multiple"
|
||||
value={value}
|
||||
|
||||
464
components/alarm/AlarmCard.tsx
Normal file
464
components/alarm/AlarmCard.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import {
|
||||
queryConfirmAlarm,
|
||||
queryrUnconfirmAlarm,
|
||||
} from "@/controller/AlarmController";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: Model.Alarm;
|
||||
onReload?: (onReload: boolean) => void;
|
||||
}
|
||||
|
||||
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
|
||||
const { colors } = useThemeContext();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => note.trim().length > 0 || alarm.confirmed,
|
||||
[note, alarm.confirmed]
|
||||
);
|
||||
|
||||
// Determine level and colors based on alarm level
|
||||
// 0: Thông tin (Xanh lá), 1: Cảnh báo (Vàng), 2: Nguy hiểm (Đỏ), 3: Khẩn cấp SOS (Tím)
|
||||
const getAlarmConfig = (level?: number) => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
// SOS - Khẩn cấp (Purple)
|
||||
return {
|
||||
level: 3,
|
||||
icon: "alert" as const,
|
||||
bgColor: colors.card,
|
||||
borderColor: "#9333EA", // Purple
|
||||
iconColor: "#9333EA",
|
||||
statusBg: "#dcfce7",
|
||||
statusText: "#166534",
|
||||
};
|
||||
case 2:
|
||||
// Nguy hiểm - Đỏ
|
||||
return {
|
||||
level: 2,
|
||||
icon: "warning" as const,
|
||||
bgColor: colors.card,
|
||||
borderColor: colors.error,
|
||||
iconColor: colors.error,
|
||||
statusBg: "#dcfce7",
|
||||
statusText: "#166534",
|
||||
};
|
||||
case 1:
|
||||
// Cảnh báo - Vàng
|
||||
return {
|
||||
level: 1,
|
||||
icon: "alert-circle" as const,
|
||||
bgColor: colors.card,
|
||||
borderColor: colors.warning,
|
||||
iconColor: colors.warning,
|
||||
statusBg: "#fef08a",
|
||||
statusText: "#713f12",
|
||||
};
|
||||
case 0:
|
||||
default:
|
||||
// Thông tin - Xanh lá
|
||||
return {
|
||||
level: 0,
|
||||
icon: "information-circle" as const,
|
||||
bgColor: colors.card,
|
||||
borderColor: colors.success,
|
||||
iconColor: colors.success,
|
||||
statusBg: "#dcfce7",
|
||||
statusText: "#166534",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getAlarmConfig(alarm.level);
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||
};
|
||||
|
||||
const ensurePayload = () => {
|
||||
if (!alarm.id || !alarm.thing_id || !alarm.time) {
|
||||
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const submitConfirm = async (action: "confirm" | "unconfirm") => {
|
||||
if (!ensurePayload()) return;
|
||||
if (action === "confirm" && note.trim().length === 0) {
|
||||
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (action === "confirm") {
|
||||
await queryConfirmAlarm({
|
||||
id: alarm.id!,
|
||||
thing_id: alarm.thing_id!,
|
||||
time: alarm.time!,
|
||||
description: note.trim(),
|
||||
});
|
||||
} else {
|
||||
await queryrUnconfirmAlarm({
|
||||
id: alarm.id!,
|
||||
thing_id: alarm.thing_id!,
|
||||
time: alarm.time!,
|
||||
});
|
||||
}
|
||||
onReload?.(true);
|
||||
} catch (error: any) {
|
||||
console.error("Cannot confirm/unconfirm alarm: ", error);
|
||||
const status = error?.response?.status ?? error?.status;
|
||||
// If server returns 404, ignore silently
|
||||
if (status !== 404) {
|
||||
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setShowModal(false);
|
||||
setNote("");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePress = (alarm: Model.Alarm) => {
|
||||
if (alarm.confirmed) {
|
||||
Alert.alert(
|
||||
"Thông báo",
|
||||
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
|
||||
[
|
||||
{ text: "Hủy", style: "cancel" },
|
||||
{
|
||||
text: "Ngừng xác nhận",
|
||||
style: "destructive",
|
||||
onPress: () => submitConfirm("unconfirm"),
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
setShowModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: config.bgColor,
|
||||
borderLeftColor: config.borderColor,
|
||||
borderLeftWidth: 5,
|
||||
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* Left Side - Icon and Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Icon */}
|
||||
<View
|
||||
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
|
||||
{/* Title and Info */}
|
||||
<View style={styles.textContainer}>
|
||||
{/* Name */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{alarm.name || alarm.thing_name || "Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Location (thing_name) and Time */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text
|
||||
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
Trạm
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.infoValue, { color: colors.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{alarm.thing_name || "Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text
|
||||
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
Thời gian
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.infoValue, { color: colors.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{formatDate(alarm.time)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Badge */}
|
||||
<TouchableOpacity
|
||||
style={styles.statusContainer}
|
||||
onPress={() => handlePress(alarm)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{
|
||||
backgroundColor: alarm.confirmed
|
||||
? "#8FD14F"
|
||||
: colors.surfaceSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: alarm.confirmed ? "#166534" : colors.text },
|
||||
]}
|
||||
>
|
||||
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{alarm.confirmed && (
|
||||
<View style={styles.rightIcon}>
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={20}
|
||||
color={alarm.confirmed ? "#78C841" : config.iconColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{ backgroundColor: colors.background },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||
Nhập ghi chú xác nhận
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: colors.text, borderColor: colors.border },
|
||||
]}
|
||||
placeholder="Nhập ghi chú"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
multiline
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
editable={!submitting}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.cancelButton,
|
||||
{ backgroundColor: colors.surfaceSecondary },
|
||||
]}
|
||||
onPress={() => {
|
||||
setShowModal(false);
|
||||
setNote("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text style={[styles.cancelText, { color: colors.text }]}>
|
||||
Hủy
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.confirmButton,
|
||||
!canSubmit && styles.disabledButton,
|
||||
]}
|
||||
onPress={() => submitConfirm("confirm")}
|
||||
disabled={submitting || !canSubmit}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.confirmText}>Xác nhận</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
// borderWidth: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
// marginRight: 5,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
titleRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
code: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
gap: 16,
|
||||
},
|
||||
infoItem: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
statusContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
borderWidth: 0.2,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
rightIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: 12,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
modalContent: {
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
padding: 12,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: 12,
|
||||
},
|
||||
modalButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: "#e5e7eb",
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: "#dc2626",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
cancelText: {
|
||||
color: "#111827",
|
||||
fontWeight: "600",
|
||||
},
|
||||
confirmText: {
|
||||
color: "#fff",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
|
||||
export default AlarmCard;
|
||||
305
components/alarm/AlarmSearchForm.tsx
Normal file
305
components/alarm/AlarmSearchForm.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import Select, { SelectOption } from "@/components/Select";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface AlarmSearchFormProps {
|
||||
initialValue?: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
};
|
||||
onSubmit: (payload: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
}) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
level: number;
|
||||
confirmed: string; // Using string for Select component compatibility
|
||||
}
|
||||
|
||||
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}) => {
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
const levelOptions: SelectOption[] = [
|
||||
{ label: "Tất cả", value: 0 },
|
||||
{ label: "Cảnh báo", value: 1 },
|
||||
{ label: "Nguy hiểm", value: 2 },
|
||||
];
|
||||
|
||||
const confirmedOptions: SelectOption[] = [
|
||||
{ label: "Tất cả", value: "" },
|
||||
{ label: "Đã xác nhận", value: "true" },
|
||||
{ label: "Chưa xác nhận", value: "false" },
|
||||
];
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
name: initialValue?.name || "",
|
||||
level: initialValue?.level || 0,
|
||||
confirmed:
|
||||
initialValue?.confirmed !== undefined
|
||||
? initialValue.confirmed.toString()
|
||||
: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
reset({
|
||||
name: initialValue.name || "",
|
||||
level: initialValue.level || 0,
|
||||
confirmed:
|
||||
initialValue.confirmed !== undefined
|
||||
? initialValue.confirmed.toString()
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}, [initialValue, reset]);
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
const payload: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
} = {
|
||||
...(data.name && { name: data.name }),
|
||||
...(data.level !== 0 && { level: data.level }),
|
||||
...(data.confirmed !== "" && {
|
||||
confirmed: data.confirmed === "true",
|
||||
}),
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset({
|
||||
name: "",
|
||||
level: 0,
|
||||
confirmed: undefined,
|
||||
});
|
||||
|
||||
// Submit empty payload to reset filters
|
||||
onSubmit({});
|
||||
onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
borderBottomColor: colors.border,
|
||||
height: "auto",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* Search Input */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<View style={styles.inputContainer}>
|
||||
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
|
||||
<View
|
||||
style={[styles.inputWrapper, { borderColor: colors.border }]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Tìm theo tên cảnh báo"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{value ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange("")}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Level and Confirmed Selects */}
|
||||
<View style={styles.row}>
|
||||
<View style={styles.halfWidth}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="level"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.selectContainer}>
|
||||
<ThemedText style={styles.label}>Mức độ</ThemedText>
|
||||
<Select
|
||||
placeholder="Chọn mức độ"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={levelOptions}
|
||||
size="middle"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.halfWidth}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmed"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.selectContainer}>
|
||||
<ThemedText style={styles.label}>Trạng thái</ThemedText>
|
||||
<Select
|
||||
placeholder="Chọn trạng thái"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={confirmedOptions}
|
||||
size="middle"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={handleReset}
|
||||
>
|
||||
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
|
||||
Đặt lại
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
|
||||
Tìm kiếm
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderBottomWidth: 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
zIndex: 100,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
overflow: "visible",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
marginBottom: 6,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
fontSize: 16,
|
||||
},
|
||||
clearButton: {
|
||||
marginLeft: 8,
|
||||
padding: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
halfWidth: {
|
||||
width: "48%",
|
||||
zIndex: 5000,
|
||||
},
|
||||
selectContainer: {
|
||||
// flex: 1, // Remove this to prevent taking full width
|
||||
zIndex: 5000,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
secondaryButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
primaryButton: {
|
||||
// backgroundColor is set dynamically
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default AlarmSearchForm;
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export type AlarmStatus = "confirmed" | "pending";
|
||||
|
||||
export interface AlarmListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
station: string;
|
||||
timestamp: number;
|
||||
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
|
||||
status: AlarmStatus;
|
||||
}
|
||||
|
||||
type AlarmProp = {
|
||||
alarmsData: AlarmListItem[];
|
||||
onPress?: (alarm: AlarmListItem) => void;
|
||||
};
|
||||
|
||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||
return (
|
||||
<FlatList
|
||||
data={alarmsData}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
|
||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||
renderItem={({ item }) => (
|
||||
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AlarmCardProps = {
|
||||
alarm: AlarmListItem;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
|
||||
alarm.level
|
||||
);
|
||||
const statusConfig = getStatusConfig(alarm.status);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
|
||||
>
|
||||
<View className="flex-row justify-between items-start">
|
||||
{/* Left content */}
|
||||
<View className="flex-row flex-1">
|
||||
{/* Icon */}
|
||||
<View
|
||||
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={getIconByLevel(alarm.level)}
|
||||
size={20}
|
||||
color={iconColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View className="flex-1">
|
||||
{/* Code */}
|
||||
<Text
|
||||
className={`text-xs font-medium mb-1 ${getCodeTextColor(
|
||||
alarm.level
|
||||
)}`}
|
||||
>
|
||||
{alarm.code}
|
||||
</Text>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="text-base font-semibold text-gray-800 mb-2">
|
||||
{alarm.title}
|
||||
</Text>
|
||||
|
||||
{/* Station and Time */}
|
||||
<View className="flex-row">
|
||||
<View className="mr-6">
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
|
||||
<Text className="text-sm text-gray-600">{alarm.station}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{formatTimestamp(alarm.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Badge */}
|
||||
{/* <View className="mt-3">
|
||||
<View
|
||||
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-medium ${statusConfig.textColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Checkmark for confirmed */}
|
||||
{/* {alarm.status === "confirmed" && (
|
||||
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
</View>
|
||||
)} */}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const getColorsByLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 3: // Danger - Red
|
||||
return {
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconColor: "#DC2626",
|
||||
iconBgColor: "bg-red-100",
|
||||
};
|
||||
case 2: // Caution - Yellow/Orange
|
||||
return {
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
iconColor: "#CA8A04",
|
||||
iconBgColor: "bg-yellow-100",
|
||||
};
|
||||
case 1: // Info - Green
|
||||
default:
|
||||
return {
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
iconColor: "#16A34A",
|
||||
iconBgColor: "bg-green-100",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "warning";
|
||||
case 2:
|
||||
return "alert-circle";
|
||||
case 1:
|
||||
default:
|
||||
return "checkmark-circle";
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeTextColor = (level: number) => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "text-red-600";
|
||||
case 2:
|
||||
return "text-yellow-600";
|
||||
case 1:
|
||||
default:
|
||||
return "text-green-600";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: AlarmStatus) => {
|
||||
switch (status) {
|
||||
case "confirmed":
|
||||
return {
|
||||
label: "Đã xác nhận",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-700",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
label: "Chờ xác nhận",
|
||||
bgColor: "bg-yellow-100",
|
||||
textColor: "text-yellow-700",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||
};
|
||||
|
||||
export default AlarmList;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
992
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal file
992
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
331
components/diary/TripCrewModal/CrewCard.tsx
Normal file
331
components/diary/TripCrewModal/CrewCard.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
65
components/diary/TripCrewModal/CrewList.tsx
Normal file
65
components/diary/TripCrewModal/CrewList.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
437
components/diary/TripCrewModal/index.tsx
Normal file
437
components/diary/TripCrewModal/index.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
187
components/diary/TripDetailSections/AlertsSection.tsx
Normal file
187
components/diary/TripDetailSections/AlertsSection.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
221
components/diary/TripDetailSections/BasicInfoSection.tsx
Normal file
221
components/diary/TripDetailSections/BasicInfoSection.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
452
components/diary/TripDetailSections/FishingLogsSection.tsx
Normal file
452
components/diary/TripDetailSections/FishingLogsSection.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
127
components/diary/TripDetailSections/SectionCard.tsx
Normal file
127
components/diary/TripDetailSections/SectionCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
4
components/diary/TripDetailSections/index.ts
Normal file
4
components/diary/TripDetailSections/index.ts
Normal 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";
|
||||
@@ -19,7 +19,7 @@ import { queryLastTrip } from "@/controller/TripController";
|
||||
import { showErrorToast } from "@/services/toast_service";
|
||||
|
||||
interface AutoFillSectionProps {
|
||||
onAutoFill: (tripData: Model.Trip, selectedShipId: string) => void;
|
||||
onAutoFill: (tripData: Model.Trip, selectedThingId: string) => void;
|
||||
}
|
||||
|
||||
export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
|
||||
@@ -36,7 +36,7 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
|
||||
things
|
||||
?.filter((thing) => thing.id != null)
|
||||
.map((thing) => ({
|
||||
id: thing.id as string,
|
||||
thingId: thing.id as string,
|
||||
shipName: thing.metadata?.ship_name || "",
|
||||
})) || [];
|
||||
|
||||
@@ -46,17 +46,17 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
|
||||
return ship.shipName.toLowerCase().includes(searchLower);
|
||||
});
|
||||
|
||||
const handleSelectShip = async (shipId: string) => {
|
||||
const handleSelectShip = async (thingId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await queryLastTrip(shipId);
|
||||
const response = await queryLastTrip(thingId);
|
||||
if (response.data) {
|
||||
// Close the modal first before showing alert
|
||||
setIsOpen(false);
|
||||
setSearchText("");
|
||||
|
||||
// Pass shipId (thingId) along with trip data for filling ShipSelector
|
||||
onAutoFill(response.data, shipId);
|
||||
// Pass thingId along with trip data for filling ShipSelector
|
||||
onAutoFill(response.data, thingId);
|
||||
|
||||
// Use Alert instead of Toast so it appears above all modals
|
||||
Alert.alert(
|
||||
@@ -182,9 +182,9 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
|
||||
{filteredShips.length > 0 ? (
|
||||
filteredShips.map((ship) => (
|
||||
<TouchableOpacity
|
||||
key={ship.id}
|
||||
key={ship.thingId}
|
||||
style={[styles.option, themedStyles.option]}
|
||||
onPress={() => handleSelectShip(ship.id)}
|
||||
onPress={() => handleSelectShip(ship.thingId)}
|
||||
>
|
||||
<View style={styles.optionContent}>
|
||||
<Ionicons
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
57
components/diary/TripFormModal/FormSection.tsx
Normal file
57
components/diary/TripFormModal/FormSection.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
390
components/diary/TripFormModal/PortSelector.tsx
Normal file
390
components/diary/TripFormModal/PortSelector.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
599
components/diary/TripFormModal/TripDurationPicker.tsx
Normal file
599
components/diary/TripFormModal/TripDurationPicker.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
120
components/diary/TripFormModal/TripFormBody.tsx
Normal file
120
components/diary/TripFormModal/TripFormBody.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
587
components/diary/TripFormModal/index.tsx
Normal file
587
components/diary/TripFormModal/index.tsx
Normal 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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
interface TripDurationPickerProps {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
onStartDateChange: (date: Date | null) => void;
|
||||
onEndDateChange: (date: Date | null) => void;
|
||||
}
|
||||
|
||||
export default function TripDurationPicker({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}: TripDurationPickerProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors, colorScheme } = useThemeContext();
|
||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return "";
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
};
|
||||
|
||||
const handleStartDateChange = (event: any, selectedDate?: Date) => {
|
||||
setShowStartPicker(Platform.OS === "ios");
|
||||
if (selectedDate) {
|
||||
onStartDateChange(selectedDate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (event: any, selectedDate?: Date) => {
|
||||
setShowEndPicker(Platform.OS === "ios");
|
||||
if (selectedDate) {
|
||||
onEndDateChange(selectedDate);
|
||||
}
|
||||
};
|
||||
|
||||
const themedStyles = {
|
||||
label: { color: colors.text },
|
||||
dateInput: {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
dateText: { color: colors.text },
|
||||
placeholder: { color: colors.textSecondary },
|
||||
pickerContainer: { backgroundColor: colors.card },
|
||||
pickerHeader: { borderBottomColor: colors.border },
|
||||
pickerTitle: { color: colors.text },
|
||||
cancelButton: { color: colors.textSecondary },
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.tripDuration")}
|
||||
</Text>
|
||||
<View style={styles.dateRangeContainer}>
|
||||
{/* Start Date */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.startDate")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!startDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{startDate ? formatDate(startDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* End Date */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.endDate")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowEndPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!endDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{endDate ? formatDate(endDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Start Date Picker */}
|
||||
{showStartPicker && (
|
||||
<Modal transparent animationType="fade" visible={showStartPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
|
||||
{t("diary.selectStartDate")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={startDate || new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleStartDateChange}
|
||||
maximumDate={endDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* End Date Picker */}
|
||||
{showEndPicker && (
|
||||
<Modal transparent animationType="fade" visible={showEndPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
|
||||
{t("diary.selectEndDate")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={endDate || new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleEndDateChange}
|
||||
minimumDate={startDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 12,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
subLabel: {
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
dateRangeContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
},
|
||||
dateSection: {
|
||||
flex: 1,
|
||||
},
|
||||
dateInput: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 15,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
pickerContainer: {
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
pickerHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
cancelButton: {
|
||||
fontSize: 16,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
doneButton: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#3B82F6",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,386 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import FishingGearList from "@/components/diary/addTripModal/FishingGearList";
|
||||
import MaterialCostList from "@/components/diary/addTripModal/MaterialCostList";
|
||||
import TripNameInput from "@/components/diary/addTripModal/TripNameInput";
|
||||
import TripDurationPicker from "@/components/diary/addTripModal/TripDurationPicker";
|
||||
import PortSelector from "@/components/diary/addTripModal/PortSelector";
|
||||
import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput";
|
||||
import ShipSelector from "./ShipSelector";
|
||||
import AutoFillSection from "./AutoFillSection";
|
||||
|
||||
|
||||
// Internal component interfaces
|
||||
export interface FishingGear {
|
||||
id: string;
|
||||
name: string;
|
||||
number: string; // Changed from quantity to number (string)
|
||||
}
|
||||
|
||||
export interface TripCost {
|
||||
id: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
cost_per_unit: number;
|
||||
total_cost: number;
|
||||
}
|
||||
|
||||
// API body interface
|
||||
export interface TripAPIBody {
|
||||
thing_id?: string; // Ship ID
|
||||
name: string;
|
||||
departure_time: string; // ISO string
|
||||
departure_port_id: number;
|
||||
arrival_time: string; // ISO string
|
||||
arrival_port_id: number;
|
||||
fishing_ground_codes: number[]; // Array of numbers
|
||||
fishing_gears: Array<{
|
||||
name: string;
|
||||
number: string;
|
||||
}>;
|
||||
trip_cost: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
cost_per_unit: number;
|
||||
total_cost: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AddTripModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
// Form state
|
||||
const [selectedShipId, setSelectedShipId] = useState<string>("");
|
||||
const [tripName, setTripName] = useState("");
|
||||
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
|
||||
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
|
||||
const [startDate, setStartDate] = useState<Date | null>(null);
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [departurePortId, setDeparturePortId] = useState<number>(1);
|
||||
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
|
||||
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // Input as string, convert to array
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form
|
||||
setSelectedShipId("");
|
||||
setTripName("");
|
||||
setFishingGears([]);
|
||||
setTripCosts([]);
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
setDeparturePortId(1);
|
||||
setArrivalPortId(1);
|
||||
setFishingGroundCodes("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle auto-fill from last trip data
|
||||
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
|
||||
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
|
||||
setSelectedShipId(selectedThingId);
|
||||
|
||||
// Fill trip name
|
||||
if (tripData.name) {
|
||||
setTripName(tripData.name);
|
||||
}
|
||||
|
||||
// Fill fishing gears
|
||||
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
|
||||
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
|
||||
id: `auto-${Date.now()}-${index}`,
|
||||
name: gear.name || "",
|
||||
number: gear.number?.toString() || "",
|
||||
}));
|
||||
setFishingGears(gears);
|
||||
}
|
||||
|
||||
// Fill trip costs
|
||||
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
|
||||
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
|
||||
id: `auto-${Date.now()}-${index}`,
|
||||
type: cost.type || "",
|
||||
amount: cost.amount || 0,
|
||||
unit: cost.unit || "",
|
||||
cost_per_unit: cost.cost_per_unit || 0,
|
||||
total_cost: cost.total_cost || 0,
|
||||
}));
|
||||
setTripCosts(costs);
|
||||
}
|
||||
|
||||
// Fill departure and arrival ports
|
||||
if (tripData.departure_port_id) {
|
||||
setDeparturePortId(tripData.departure_port_id);
|
||||
}
|
||||
if (tripData.arrival_port_id) {
|
||||
setArrivalPortId(tripData.arrival_port_id);
|
||||
}
|
||||
|
||||
// Fill fishing ground codes
|
||||
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
|
||||
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Parse fishing ground codes from comma-separated string to array of numbers
|
||||
const fishingGroundCodesArray = fishingGroundCodes
|
||||
.split(",")
|
||||
.map((code) => parseInt(code.trim()))
|
||||
.filter((code) => !isNaN(code));
|
||||
|
||||
// Format API body
|
||||
const apiBody: TripAPIBody = {
|
||||
thing_id: selectedShipId || undefined,
|
||||
name: tripName,
|
||||
departure_time: startDate ? startDate.toISOString() : "",
|
||||
departure_port_id: departurePortId,
|
||||
arrival_time: endDate ? endDate.toISOString() : "",
|
||||
arrival_port_id: arrivalPortId,
|
||||
fishing_ground_codes: fishingGroundCodesArray,
|
||||
fishing_gears: fishingGears.map((gear) => ({
|
||||
name: gear.name,
|
||||
number: gear.number,
|
||||
})),
|
||||
trip_cost: tripCosts.map((cost) => ({
|
||||
type: cost.type,
|
||||
amount: cost.amount,
|
||||
unit: cost.unit,
|
||||
cost_per_unit: cost.cost_per_unit,
|
||||
total_cost: cost.total_cost,
|
||||
})),
|
||||
};
|
||||
|
||||
// Simulate API call - log the formatted data
|
||||
console.log("=== Submitting Trip Data (API Format) ===");
|
||||
console.log(JSON.stringify(apiBody, null, 2));
|
||||
console.log("=== End Trip Data ===");
|
||||
|
||||
// Reset form and close modal
|
||||
handleCancel();
|
||||
};
|
||||
|
||||
const themedStyles = {
|
||||
modalContainer: {
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
header: {
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
},
|
||||
footer: {
|
||||
borderTopColor: colors.separator,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
cancelButtonText: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modalContainer, themedStyles.modalContainer]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, themedStyles.header]}>
|
||||
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, themedStyles.title]}>
|
||||
{t("diary.addTrip")}
|
||||
</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Auto Fill Section */}
|
||||
<AutoFillSection onAutoFill={handleAutoFill} />
|
||||
|
||||
{/* Ship Selector */}
|
||||
<ShipSelector
|
||||
selectedShipId={selectedShipId}
|
||||
onChange={setSelectedShipId}
|
||||
/>
|
||||
|
||||
{/* Trip Name */}
|
||||
<TripNameInput value={tripName} onChange={setTripName} />
|
||||
|
||||
{/* Fishing Gear List */}
|
||||
<FishingGearList
|
||||
items={fishingGears}
|
||||
onChange={setFishingGears}
|
||||
/>
|
||||
|
||||
{/* Trip Cost List */}
|
||||
<MaterialCostList
|
||||
items={tripCosts}
|
||||
onChange={setTripCosts}
|
||||
/>
|
||||
|
||||
{/* Trip Duration */}
|
||||
<TripDurationPicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Port Selector */}
|
||||
<PortSelector
|
||||
departurePortId={departurePortId}
|
||||
arrivalPortId={arrivalPortId}
|
||||
onDeparturePortChange={setDeparturePortId}
|
||||
onArrivalPortChange={setArrivalPortId}
|
||||
/>
|
||||
|
||||
{/* Fishing Ground Codes */}
|
||||
<BasicInfoInput
|
||||
fishingGroundCodes={fishingGroundCodes}
|
||||
onChange={setFishingGroundCodes}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.footer, themedStyles.footer]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, themedStyles.cancelButton]}
|
||||
onPress={handleCancel}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, themedStyles.submitButton]}
|
||||
onPress={handleSubmit}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>
|
||||
{t("diary.createTrip")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "90%",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -4,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
width: 32,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
16
components/manager/devices.tsx
Normal file
16
components/manager/devices.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function DevicesScreen() {
|
||||
console.log("Gọi API 2");
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText>Quản lý thiết bị</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
});
|
||||
17
components/manager/fleets.tsx
Normal file
17
components/manager/fleets.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
export default function FleetsScreen() {
|
||||
console.log("Gọi API 3");
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText>Quản lý đội tàu</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
});
|
||||
853
components/manager/ship_components/CreateOrUpdateShip.tsx
Normal file
853
components/manager/ship_components/CreateOrUpdateShip.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
import Select, { SelectOption } from "@/components/Select";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { Colors } from "@/config";
|
||||
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
|
||||
import { usePort } from "@/state/use-ports";
|
||||
import { useShipGroups } from "@/state/use-ship-groups";
|
||||
import { useShipTypes } from "@/state/use-ship-types";
|
||||
import { useThings } from "@/state/use-thing";
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
interface CreateOrUpdateShipProps {
|
||||
initialValue?: Model.ShipBodyRequest;
|
||||
isOpen?: boolean;
|
||||
type?: "create" | "update";
|
||||
onSubmit?: (data: Model.ShipBodyRequest) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const CreateOrUpdateShip = (props: CreateOrUpdateShipProps) => {
|
||||
const { colors, colorScheme } = useTheme();
|
||||
const styles = useMemo(
|
||||
() => createStyles(colors, colorScheme),
|
||||
[colors, colorScheme]
|
||||
);
|
||||
|
||||
const { shipTypes, getShipTypes } = useShipTypes();
|
||||
const { ports, getPorts } = usePort();
|
||||
const { shipGroups, getShipGroups } = useShipGroups();
|
||||
const { things, getThings } = useThings();
|
||||
|
||||
// State for date picker
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
} = useForm<Model.ShipBodyRequest>({
|
||||
defaultValues: props.initialValue || {
|
||||
fishing_license_expiry_date: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Watch the date field for picker display
|
||||
const dateValue = watch("fishing_license_expiry_date");
|
||||
|
||||
// Fetch data when modal opens
|
||||
useEffect(() => {
|
||||
if (props.isOpen) {
|
||||
// Fetch ship types if not loaded
|
||||
if (shipTypes === null || shipTypes.length === 0) {
|
||||
getShipTypes();
|
||||
}
|
||||
|
||||
// Fetch ports if not loaded
|
||||
if (ports === null) {
|
||||
getPorts();
|
||||
}
|
||||
|
||||
// Fetch ship groups if not loaded
|
||||
if (shipGroups === null) {
|
||||
getShipGroups();
|
||||
}
|
||||
|
||||
// Fetch things when modal opens
|
||||
const payloadThings: Model.SearchThingBody = {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
order: "name",
|
||||
dir: "asc",
|
||||
};
|
||||
getThings(payloadThings);
|
||||
|
||||
// Reset form with initial values if provided
|
||||
if (props.initialValue) {
|
||||
reset(props.initialValue);
|
||||
}
|
||||
}
|
||||
}, [props.isOpen, props.initialValue, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.type === "create") {
|
||||
reset(props.initialValue);
|
||||
}
|
||||
}, [props.isOpen]);
|
||||
|
||||
// Prepare options for selects
|
||||
const shipTypeOptions = useMemo<SelectOption[]>(() => {
|
||||
return (shipTypes || []).map((type) => ({
|
||||
label: type.name || "",
|
||||
value: type.id || 0,
|
||||
}));
|
||||
}, [shipTypes]);
|
||||
|
||||
const portOptions = useMemo<SelectOption[]>(() => {
|
||||
return (ports?.ports || []).map((port) => ({
|
||||
label: port.name || "",
|
||||
value: port.id || 0,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
const shipGroupOptions = useMemo<SelectOption[]>(() => {
|
||||
return (shipGroups || []).map((group) => ({
|
||||
label: group.name || "",
|
||||
value: group.id || "",
|
||||
}));
|
||||
}, [shipGroups]);
|
||||
|
||||
const thingOptions = useMemo<SelectOption[]>(() => {
|
||||
// Filter things that are not assigned to any ship
|
||||
const unassignedThings = (things || []).filter(
|
||||
(thing) => !thing.metadata?.ship_id
|
||||
);
|
||||
return unassignedThings.map((thing) => ({
|
||||
label: thing.name || "",
|
||||
value: thing.id || "",
|
||||
}));
|
||||
}, [things]);
|
||||
|
||||
// Handle date picker change
|
||||
const handleDateChange = (_: any, selectedDate?: Date) => {
|
||||
if (selectedDate) {
|
||||
setValue("fishing_license_expiry_date", selectedDate);
|
||||
}
|
||||
// On Android, close picker after selection
|
||||
// On iOS, keep it open until user confirms with the button
|
||||
if (Platform.OS === "android") {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDateForDisplay = (date: Date | string | undefined) => {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (data: Model.ShipBodyRequest) => {
|
||||
// Ensure numeric fields are numbers
|
||||
const payload: Model.ShipBodyRequest = {
|
||||
...data,
|
||||
ship_type: Number(data.ship_type),
|
||||
home_port: Number(data.home_port),
|
||||
ship_length: Number(data.ship_length),
|
||||
ship_power: Number(data.ship_power),
|
||||
fishing_license_expiry_date: data.fishing_license_expiry_date,
|
||||
};
|
||||
|
||||
props.onSubmit?.(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={props.isOpen}
|
||||
onRequestClose={props.onClose}
|
||||
>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={["top", "left", "right"]}>
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.backdrop} onPress={props.onClose} />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.dragIndicator} />
|
||||
<ThemedText style={styles.headerTitle}>
|
||||
{props.type === "create" ? "Thêm tàu mới" : "Cập nhật tàu"}
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
onPress={props.onClose}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<ThemedText style={styles.closeButtonText}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Form Content */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Registration Number - Only show in create mode */}
|
||||
{props.type === "create" && (
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Số đăng ký *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="reg_number"
|
||||
rules={{ required: "Vui lòng nhập số đăng ký" }}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: errors.reg_number
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
placeholder="Nhập số đăng ký"
|
||||
onBlur={onBlur}
|
||||
onChangeText={(text) => onChange(text.trim())}
|
||||
value={value}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.reg_number && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.reg_number.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Ship Name */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Tên tàu *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{ required: "Vui lòng nhập tên tàu" }}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ borderColor: errors.name ? "red" : colors.border },
|
||||
]}
|
||||
placeholder="Nhập tên tàu"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.name.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Ship Type */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Loại tàu *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_type"
|
||||
rules={{ required: "Vui lòng chọn loại tàu" }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={shipTypeOptions}
|
||||
placeholder="Chọn loại tàu"
|
||||
style={[
|
||||
styles.selectInput,
|
||||
{
|
||||
borderColor: errors.ship_type
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.ship_type && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.ship_type.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Home Port */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>
|
||||
Cảng đăng ký đỗ *
|
||||
</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="home_port"
|
||||
rules={{ required: "Vui lòng chọn cảng đăng ký" }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={portOptions}
|
||||
placeholder="Chọn cảng đăng ký"
|
||||
style={[
|
||||
styles.selectInput,
|
||||
{
|
||||
borderColor: errors.home_port
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.home_port && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.home_port.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Fishing License Number */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Số giấy phép *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="fishing_license_number"
|
||||
rules={{ required: "Vui lòng nhập số giấy phép" }}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: errors.fishing_license_number
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
placeholder="Nhập số giấy phép"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.fishing_license_number && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.fishing_license_number.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Fishing License Expiry Date */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Ngày hết hạn *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="fishing_license_expiry_date"
|
||||
rules={{
|
||||
required: "Vui lòng chọn ngày hết hạn",
|
||||
validate: (date) => {
|
||||
if (!date) return "Vui lòng chọn ngày hết hạn";
|
||||
const selectedDate = new Date(date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (selectedDate < today) {
|
||||
return "Ngày hết hạn không thể là ngày trong quá khứ";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
style={[
|
||||
styles.input,
|
||||
styles.dateInput,
|
||||
{
|
||||
borderColor: errors.fishing_license_expiry_date
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={{
|
||||
color: value ? colors.text : colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{formatDateForDisplay(value) || "Chọn ngày hết hạn"}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
{errors.fishing_license_expiry_date && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.fishing_license_expiry_date.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Ship Length */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Chiều dài (m) *</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_length"
|
||||
rules={{
|
||||
required: "Vui lòng nhập chiều dài",
|
||||
pattern: {
|
||||
value: /^\d*\.?\d+$/,
|
||||
message: "Vui lòng nhập số hợp lệ",
|
||||
},
|
||||
validate: (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
return "Chiều dài phải lớn hơn 0";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: errors.ship_length
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
placeholder="Nhập chiều dài tàu"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value?.toString()}
|
||||
keyboardType="numeric"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.ship_length && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.ship_length.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Ship Power */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>
|
||||
Công suất (mã lực) *
|
||||
</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_power"
|
||||
rules={{
|
||||
required: "Vui lòng nhập công suất",
|
||||
pattern: {
|
||||
value: /^\d*\.?\d+$/,
|
||||
message: "Vui lòng nhập số hợp lệ",
|
||||
},
|
||||
validate: (value) => {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
return "Công suất phải lớn hơn 0";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
borderColor: errors.ship_power
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
placeholder="Nhập công suất tàu"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value?.toString()}
|
||||
keyboardType="numeric"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.ship_power && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.ship_power.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Ship Group - Only show in update mode */}
|
||||
{props.type === "update" && (
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>Đội tàu</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="ship_group_id"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={shipGroupOptions}
|
||||
placeholder="Chọn đội tàu"
|
||||
style={styles.selectInput}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Device/Thing - Only show in create mode */}
|
||||
{props.type === "create" && (
|
||||
<View style={styles.fieldGroup}>
|
||||
<ThemedText style={styles.label}>
|
||||
Thiết bị kết nối *
|
||||
</ThemedText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="thing_id"
|
||||
rules={{ required: "Vui lòng chọn thiết bị kết nối" }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={thingOptions}
|
||||
placeholder="Chọn thiết bị kết nối"
|
||||
style={[
|
||||
styles.selectInput,
|
||||
{
|
||||
borderColor: errors.thing_id
|
||||
? "red"
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.thing_id && (
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errors.thing_id.message}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.resetButton, { borderColor: colors.border }]}
|
||||
onPress={() => {
|
||||
reset(props.initialValue || {});
|
||||
}}
|
||||
>
|
||||
<ThemedText
|
||||
style={[styles.resetButtonText, { color: colors.text }]}
|
||||
>
|
||||
Nhập lại
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.submitButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ThemedText style={styles.submitButtonText}>
|
||||
{props.type === "create" ? "Thêm tàu" : "Cập nhật"}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{/* Date Picker Modal - Only show on Android as modal, iOS shows inline */}
|
||||
{Platform.OS === "android" && showDatePicker && (
|
||||
<DateTimePicker
|
||||
value={
|
||||
typeof dateValue === "string"
|
||||
? new Date(dateValue)
|
||||
: dateValue || new Date()
|
||||
}
|
||||
mode="date"
|
||||
display="default"
|
||||
onChange={handleDateChange}
|
||||
minimumDate={new Date()}
|
||||
/>
|
||||
)}
|
||||
{Platform.OS === "ios" && showDatePicker && (
|
||||
<Modal
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
visible={showDatePicker}
|
||||
onRequestClose={() => setShowDatePicker(false)}
|
||||
>
|
||||
<SafeAreaView style={styles.datePickerModal}>
|
||||
<View style={styles.datePickerContent}>
|
||||
<View style={styles.datePickerHeader}>
|
||||
<ThemedText style={styles.datePickerTitle}>
|
||||
Chọn ngày hết hạn
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => setShowDatePicker(false)}>
|
||||
<ThemedText style={styles.datePickerClose}>✕</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={
|
||||
typeof dateValue === "string"
|
||||
? new Date(dateValue)
|
||||
: dateValue || new Date()
|
||||
}
|
||||
mode="date"
|
||||
display="spinner"
|
||||
onChange={handleDateChange}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
minimumDate={new Date()}
|
||||
style={styles.datePickerIOS}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.datePickerButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={() => setShowDatePicker(false)}
|
||||
>
|
||||
<ThemedText style={styles.datePickerButtonText}>
|
||||
Xác nhận
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.3)",
|
||||
},
|
||||
modalContent: {
|
||||
height: "90%",
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -4,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
position: "relative",
|
||||
},
|
||||
dragIndicator: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: colors.border,
|
||||
borderRadius: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
textAlign: "center",
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 16,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
fontWeight: "300",
|
||||
color: colors.text,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: Platform.OS === "ios" ? 120 : 80,
|
||||
},
|
||||
fieldGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
},
|
||||
selectInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
dateInput: {
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
color: "red",
|
||||
marginTop: 4,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
resetButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
resetButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
submitButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
submitButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Date Picker Modal Styles
|
||||
datePickerModal: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
datePickerContent: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
datePickerHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
datePickerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: colors.text,
|
||||
},
|
||||
datePickerClose: {
|
||||
fontSize: 20,
|
||||
color: colors.text,
|
||||
},
|
||||
datePickerIOS: {
|
||||
height: 200,
|
||||
marginTop: 20,
|
||||
},
|
||||
datePickerButton: {
|
||||
marginHorizontal: 20,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
marginTop: 20,
|
||||
},
|
||||
datePickerButtonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default CreateOrUpdateShip;
|
||||
522
components/manager/ship_components/ShipCard.tsx
Normal file
522
components/manager/ship_components/ShipCard.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { queryShipsImage } from "@/controller/DeviceController";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { useGroup } from "@/state/use-group";
|
||||
import { usePort } from "@/state/use-ports";
|
||||
import { useShipTypes } from "@/state/use-ship-types";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
interface ShipCardProps {
|
||||
ship: Model.Ship;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export default function ShipCard({ ship, onPress }: ShipCardProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { ports, getPorts } = usePort();
|
||||
const { shipTypes, getShipTypes } = useShipTypes();
|
||||
const [shipImage, setShipImage] = useState<string | null>(null);
|
||||
const { groups, getUserGroups, getChildrenOfGroups, childrenOfGroups } =
|
||||
useGroup();
|
||||
useEffect(() => {
|
||||
if (ports === null) {
|
||||
getPorts();
|
||||
}
|
||||
}, [ports, getPorts]);
|
||||
useEffect(() => {
|
||||
if (!shipTypes || shipTypes.length === 0) {
|
||||
getShipTypes();
|
||||
}
|
||||
}, [shipTypes, getShipTypes]);
|
||||
useEffect(() => {
|
||||
if (groups === null) {
|
||||
getUserGroups();
|
||||
}
|
||||
}, [groups, getUserGroups]);
|
||||
useEffect(() => {
|
||||
if (groups && ship.ship_group_id) {
|
||||
const groupId = groups?.groups?.[0]?.id || "";
|
||||
// childrenOfGroups is initialised as null in the store; check for null to fetch once
|
||||
if (groupId && childrenOfGroups == null) {
|
||||
getChildrenOfGroups(groupId);
|
||||
}
|
||||
}
|
||||
}, [groups, childrenOfGroups, getChildrenOfGroups]);
|
||||
// Themed styles
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const loadShipImage = async () => {
|
||||
try {
|
||||
const resp = await queryShipsImage(ship.id || "");
|
||||
const contentType = resp.headers["content-type"] || "image/jpeg";
|
||||
const uint8 = new Uint8Array(resp.data); // ArrayBuffer -> Uint8Array
|
||||
const base64 = fromByteArray(uint8); // base64-js
|
||||
const uri = `data:${contentType};base64,${base64}`;
|
||||
|
||||
if (!mounted) return;
|
||||
// assign received value to state if present; adapt to actual resp shape as needed
|
||||
setShipImage(uri);
|
||||
} catch (error) {
|
||||
// console.log("Error when get image: ", error);
|
||||
}
|
||||
};
|
||||
loadShipImage();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [ship]);
|
||||
const themedStyles = {
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
shadowColor: colors.text,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: colors.separator,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: colors.primary + "15",
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
infoBox: {
|
||||
backgroundColor: colors.primary + "10",
|
||||
},
|
||||
infoIcon: {
|
||||
color: colors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
// ============ IMAGE VARIANT ============
|
||||
if (shipImage) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.imageCard, themedStyles.card]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{/* Image Section */}
|
||||
<View style={styles.imageContainer}>
|
||||
{shipImage ? (
|
||||
<Image
|
||||
source={{ uri: shipImage }}
|
||||
style={styles.shipImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.imagePlaceholder,
|
||||
{ backgroundColor: colors.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="boat" size={48} color={colors.textSecondary} />
|
||||
</View>
|
||||
)}
|
||||
{/* Ship Type Badge */}
|
||||
{shipTypes && (
|
||||
<View style={styles.typeBadge}>
|
||||
<Ionicons name="boat-outline" size={14} color="#fff" />
|
||||
<Text style={styles.typeBadgeText}>
|
||||
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
|
||||
"Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Section */}
|
||||
<View style={styles.imageCardContent}>
|
||||
{/* Title & Registration */}
|
||||
<Text style={[styles.imageCardTitle, themedStyles.title]}>
|
||||
{ship.name || "Unknown Ship"}
|
||||
</Text>
|
||||
<View style={styles.regRow}>
|
||||
<View style={[styles.regBadge, themedStyles.badge]}>
|
||||
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
|
||||
{ship.reg_number || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
{childrenOfGroups && (
|
||||
<View style={styles.locationRow}>
|
||||
<Ionicons
|
||||
name="location"
|
||||
size={14}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.locationText, themedStyles.subtitle]}>
|
||||
{childrenOfGroups.groups?.find(
|
||||
(group) => group?.metadata?.code === ship.province_code
|
||||
)?.name || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info Grid */}
|
||||
<View style={styles.imageInfoGrid}>
|
||||
<InfoBox
|
||||
icon="resize"
|
||||
label="Length"
|
||||
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<InfoBox
|
||||
icon="flash"
|
||||
label="Engine Power"
|
||||
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<InfoBox
|
||||
icon="document-text"
|
||||
label="License"
|
||||
value={ship.fishing_license_number || "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<InfoBox
|
||||
icon="navigate"
|
||||
label="Home Port"
|
||||
value={
|
||||
ports?.ports
|
||||
? ports.ports.find((port) => port.id === ship.home_port)
|
||||
?.name || "-"
|
||||
: "-"
|
||||
}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ COMPACT VARIANT ============
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.compactCard, themedStyles.card]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.compactHeader}>
|
||||
<View style={[styles.shipIcon, themedStyles.infoBox]}>
|
||||
<Ionicons name="boat" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.compactHeaderText}>
|
||||
<Text style={[styles.compactTitle, themedStyles.title]}>
|
||||
{ship.name || "Unknown Ship"}
|
||||
</Text>
|
||||
<Text style={[styles.compactSubtitle, themedStyles.subtitle]}>
|
||||
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
|
||||
"Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.regBadge, themedStyles.badge]}>
|
||||
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
|
||||
{ship.reg_number || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info Grid */}
|
||||
<View style={styles.compactInfoGrid}>
|
||||
<CompactInfoBox
|
||||
icon="resize"
|
||||
label="Length"
|
||||
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<CompactInfoBox
|
||||
icon="flash"
|
||||
label="Power"
|
||||
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<CompactInfoBox
|
||||
icon="navigate"
|
||||
label="Port"
|
||||
value={
|
||||
ports?.ports
|
||||
? ports.ports.find((port) => port.id === ship.home_port)?.name ||
|
||||
"-"
|
||||
: "-"
|
||||
}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<CompactInfoBox
|
||||
icon="document-text"
|
||||
label="License"
|
||||
value={ship.fishing_license_number || "-"}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
<CompactInfoBox
|
||||
icon="location"
|
||||
label="Province"
|
||||
value={
|
||||
childrenOfGroups?.groups?.find(
|
||||
(group) => group?.metadata?.code === ship.province_code
|
||||
)?.name || "-"
|
||||
}
|
||||
themedStyles={themedStyles}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Footer - IMO & MMSI */}
|
||||
{(ship.imo_number || ship.mmsi_number) && (
|
||||
<>
|
||||
<View style={[styles.divider, themedStyles.divider]} />
|
||||
<View style={styles.footerInfo}>
|
||||
{ship.imo_number && (
|
||||
<View style={styles.footerRow}>
|
||||
<Text style={[styles.footerLabel, themedStyles.label]}>
|
||||
IMO Number:
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, themedStyles.value]}>
|
||||
{ship.imo_number}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{ship.mmsi_number && (
|
||||
<View style={styles.footerRow}>
|
||||
<Text style={[styles.footerLabel, themedStyles.label]}>
|
||||
MMSI Number:
|
||||
</Text>
|
||||
<Text style={[styles.footerValue, themedStyles.value]}>
|
||||
{ship.mmsi_number}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ SUB-COMPONENTS ============
|
||||
|
||||
interface InfoBoxProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
value: string;
|
||||
themedStyles: any;
|
||||
}
|
||||
|
||||
function InfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
|
||||
return (
|
||||
<View style={styles.infoBox}>
|
||||
<Ionicons name={icon} size={18} color={themedStyles.infoIcon.color} />
|
||||
<Text style={[styles.infoLabel, themedStyles.label]}>{label}</Text>
|
||||
<Text style={[styles.infoValue, themedStyles.value]}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactInfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
|
||||
return (
|
||||
<View style={[styles.compactInfoBox, themedStyles.infoBox]}>
|
||||
<Ionicons name={icon} size={16} color={themedStyles.infoIcon.color} />
|
||||
<View style={styles.compactInfoText}>
|
||||
<Text style={[styles.compactInfoLabel, themedStyles.label]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={[styles.compactInfoValue, themedStyles.value]}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ STYLES ============
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// === IMAGE VARIANT ===
|
||||
imageCard: {
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginVertical: 8,
|
||||
marginHorizontal: 16,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
},
|
||||
imageContainer: {
|
||||
height: 180,
|
||||
position: "relative",
|
||||
},
|
||||
shipImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
typeBadge: {
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.9)",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
},
|
||||
typeBadgeText: {
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
imageCardContent: {
|
||||
padding: 16,
|
||||
},
|
||||
imageCardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
marginBottom: 8,
|
||||
},
|
||||
regRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
regBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
},
|
||||
regBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
locationRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
locationText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
imageInfoGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
},
|
||||
infoBox: {
|
||||
width: "47%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
|
||||
// === COMPACT VARIANT ===
|
||||
compactCard: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
marginVertical: 8,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
compactHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
shipIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
compactHeaderText: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
compactSubtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
compactInfoGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 10,
|
||||
},
|
||||
compactInfoBox: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
gap: 8,
|
||||
minWidth: "47%",
|
||||
flexGrow: 1,
|
||||
},
|
||||
compactInfoText: {
|
||||
flex: 1,
|
||||
},
|
||||
compactInfoLabel: {
|
||||
fontSize: 11,
|
||||
},
|
||||
compactInfoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
footerInfo: {
|
||||
gap: 6,
|
||||
},
|
||||
footerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
footerLabel: {
|
||||
fontSize: 13,
|
||||
},
|
||||
footerValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
145
components/manager/ships.tsx
Normal file
145
components/manager/ships.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import {
|
||||
queryCreateShip,
|
||||
queryUpdateShip,
|
||||
} from "@/controller/DeviceController";
|
||||
import { useTheme } from "@/hooks/use-theme-context";
|
||||
import { showSuccessToast } from "@/services/toast_service";
|
||||
import { useShip } from "@/state/use-ship";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import CreateOrUpdateShip from "./ship_components/CreateOrUpdateShip";
|
||||
import ShipCard from "./ship_components/ShipCard";
|
||||
|
||||
interface ShipUpdateData {
|
||||
ship_id: string;
|
||||
body: Model.ShipBodyRequest;
|
||||
}
|
||||
|
||||
export default function ShipsScreen() {
|
||||
const { ships, getShip } = useShip();
|
||||
const { colors } = useTheme();
|
||||
const [ship, setShip] = useState<ShipUpdateData | null>(null);
|
||||
const [showUpdateShip, setShowUpdateShip] = useState<boolean>(false);
|
||||
const [isCreateShip, setIsCreateShip] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
if (ships === null) {
|
||||
getShip();
|
||||
}
|
||||
}, [ships]);
|
||||
const handleClickShip = async (ship: Model.Ship) => {
|
||||
const shipBodyRequest: Model.ShipBodyRequest = {
|
||||
name: ship.name,
|
||||
reg_number: ship.reg_number,
|
||||
imo_number: ship.imo_number,
|
||||
mmsi_number: ship.mmsi_number,
|
||||
thing_id: ship.thing_id,
|
||||
ship_type: ship.ship_type,
|
||||
owner_id: ship.owner_id,
|
||||
home_port: ship.home_port,
|
||||
ship_length: ship.ship_length,
|
||||
ship_power: ship.ship_power,
|
||||
ship_group_id: ship.ship_group_id,
|
||||
fishing_license_number: ship.fishing_license_number,
|
||||
fishing_license_expiry_date: ship.fishing_license_expiry_date,
|
||||
};
|
||||
setShip({ ship_id: ship.id!, body: shipBodyRequest });
|
||||
setIsCreateShip(false); // Đảm bảo là mode update khi edit
|
||||
setShowUpdateShip(true);
|
||||
};
|
||||
const handleSubmitForm = async (body: Model.ShipBodyRequest) => {
|
||||
try {
|
||||
let resp;
|
||||
if (isCreateShip) {
|
||||
resp = await queryCreateShip(body);
|
||||
if (resp.status === 201) {
|
||||
showSuccessToast("Thêm tàu mới thành công");
|
||||
}
|
||||
} else {
|
||||
resp = await queryUpdateShip(ship!.ship_id, body);
|
||||
if (resp.status === 200) {
|
||||
showSuccessToast("Cập nhật thông tin tàu thành công");
|
||||
}
|
||||
}
|
||||
setShowUpdateShip(false);
|
||||
await getShip();
|
||||
} catch (error) {
|
||||
console.error("Error when update/create Ship: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewShip = () => {
|
||||
setShip(null);
|
||||
setIsCreateShip(true);
|
||||
setShowUpdateShip(true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ScrollView>
|
||||
<ThemedView style={styles.container}>
|
||||
{ships?.map((ship) => (
|
||||
<ShipCard
|
||||
key={ship.id}
|
||||
ship={ship}
|
||||
onPress={() => handleClickShip(ship)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Thêm khoảng trống ở cuối để không bị FAB che */}
|
||||
<View style={styles.bottomPadding} />
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.fab, { backgroundColor: colors.primary }]}
|
||||
onPress={handleCreateNewShip}
|
||||
>
|
||||
<ThemedText style={styles.fabText}>+</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<CreateOrUpdateShip
|
||||
isOpen={showUpdateShip}
|
||||
initialValue={ship?.body || undefined}
|
||||
type={isCreateShip ? "create" : "update"}
|
||||
onClose={() => setShowUpdateShip(false)}
|
||||
onSubmit={handleSubmitForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingBottom: 20, // Khoảng trống cho FAB
|
||||
},
|
||||
bottomPadding: {
|
||||
height: 50, // Thêm khoảng trống ở cuối
|
||||
},
|
||||
fab: {
|
||||
position: "absolute",
|
||||
bottom: 30,
|
||||
right: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
fabText: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#ffffff",
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import {
|
||||
queryDeleteSos,
|
||||
queryGetSos,
|
||||
querySendSosMessage,
|
||||
} from "@/controller/DeviceController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { showErrorToast } from "@/services/toast_service";
|
||||
import { sosMessage } from "@/utils/sosUtils";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import IconButton from "../IconButton";
|
||||
import Select from "../Select";
|
||||
import Modal from "../ui/modal";
|
||||
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||
|
||||
const SosButton = () => {
|
||||
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
||||
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
|
||||
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const { t } = useI18n();
|
||||
|
||||
// Theme colors
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
// Dynamic styles
|
||||
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
|
||||
|
||||
const sosOptions = [
|
||||
...sosMessage.map((msg) => ({
|
||||
ma: msg.ma,
|
||||
moTa: msg.moTa,
|
||||
label: msg.moTa,
|
||||
value: msg.ma,
|
||||
})),
|
||||
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
|
||||
];
|
||||
|
||||
const getSosData = async () => {
|
||||
try {
|
||||
const response = await queryGetSos();
|
||||
// console.log("SoS ResponseL: ", response);
|
||||
|
||||
setSosData(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch SOS data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSosData();
|
||||
}, []);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
||||
newErrors.customMessage = t("home.sos.statusRequired");
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleConfirmSos = async () => {
|
||||
if (!validateForm()) {
|
||||
console.log("Form chưa validate");
|
||||
return; // Không đóng modal nếu validate fail
|
||||
}
|
||||
|
||||
let messageToSend = "";
|
||||
if (selectedSosMessage === 999) {
|
||||
messageToSend = customMessage.trim();
|
||||
} else {
|
||||
const selectedOption = sosOptions.find(
|
||||
(opt) => opt.ma === selectedSosMessage
|
||||
);
|
||||
messageToSend = selectedOption ? selectedOption.moTa : "";
|
||||
}
|
||||
|
||||
// Gửi dữ liệu đi
|
||||
await sendSosMessage(messageToSend);
|
||||
|
||||
// Đóng modal và reset form sau khi gửi thành công
|
||||
setShowConfirmSosDialog(false);
|
||||
setSelectedSosMessage(null);
|
||||
setCustomMessage("");
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleClickButton = async (isActive: boolean) => {
|
||||
console.log("Is Active: ", isActive);
|
||||
|
||||
if (isActive) {
|
||||
const resp = await queryDeleteSos();
|
||||
if (resp.status === 200) {
|
||||
await getSosData();
|
||||
}
|
||||
} else {
|
||||
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
|
||||
setShowConfirmSosDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sendSosMessage = async (message: string) => {
|
||||
try {
|
||||
const resp = await querySendSosMessage(message);
|
||||
if (resp.status === 200) {
|
||||
await getSosData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when send sos: ", error);
|
||||
showErrorToast(t("home.sos.sendError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
||||
type="danger"
|
||||
size="middle"
|
||||
onPress={() => handleClickButton(sosData?.active || false)}
|
||||
style={{ borderRadius: 20 }}
|
||||
>
|
||||
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
||||
</IconButton>
|
||||
<Modal
|
||||
open={showConfirmSosDialog}
|
||||
onCancel={() => {
|
||||
setShowConfirmSosDialog(false);
|
||||
setSelectedSosMessage(null);
|
||||
setCustomMessage("");
|
||||
setErrors({});
|
||||
}}
|
||||
okText={t("home.sos.confirm")}
|
||||
cancelText={t("home.sos.cancel")}
|
||||
title={t("home.sos.title")}
|
||||
centered
|
||||
onOk={handleConfirmSos}
|
||||
>
|
||||
{/* Select Nội dung SOS */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
||||
|
||||
<Select
|
||||
value={selectedSosMessage ?? undefined}
|
||||
options={sosOptions}
|
||||
placeholder={t("home.sos.selectReason")}
|
||||
onChange={(value) => {
|
||||
setSelectedSosMessage(value as number);
|
||||
// Clear custom message nếu chọn khác lý do
|
||||
if (value !== 999) {
|
||||
setCustomMessage("");
|
||||
}
|
||||
// Clear error if exists
|
||||
if (errors.sosMessage) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors.sosMessage;
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
showSearch={false}
|
||||
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
||||
/>
|
||||
{errors.sosMessage && (
|
||||
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Input Custom Message nếu chọn "Khác" */}
|
||||
{selectedSosMessage === 999 && (
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
errors.customMessage ? styles.errorInput : {},
|
||||
]}
|
||||
placeholder={t("home.sos.enterStatus")}
|
||||
placeholderTextColor={textColor + '99'} // Add transparency
|
||||
value={customMessage}
|
||||
onChangeText={(text) => {
|
||||
setCustomMessage(text);
|
||||
if (text.trim() !== "") {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors.customMessage;
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
{errors.customMessage && (
|
||||
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
color: textColor,
|
||||
},
|
||||
errorBorder: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
errorInput: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
errorText: {
|
||||
color: errorColor,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SosButton;
|
||||
@@ -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,7 +72,12 @@ api.interceptors.response.use(
|
||||
statusText ||
|
||||
"Unknown error";
|
||||
|
||||
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
handle401();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const TOKEN = "token";
|
||||
export const DOMAIN = "domain";
|
||||
export const UID = "user-id";
|
||||
export const ROLE = "role";
|
||||
export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
|
||||
export const MAP_POLYLINE_BAN = "ban-polyline";
|
||||
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||
@@ -36,14 +38,13 @@ export const STATUS_SOS = 3;
|
||||
|
||||
// API Path Constants
|
||||
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";
|
||||
@@ -55,3 +56,17 @@ export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
||||
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
|
||||
export const API_GET_SHIP_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_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";
|
||||
|
||||
15
controller/AlarmController.ts
Normal file
15
controller/AlarmController.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { api } from "@/config";
|
||||
import { API_GET_ALARM, API_MANAGER_ALARM } from "@/constants";
|
||||
|
||||
export async function queryAlarms(payload: Model.AlarmPayload) {
|
||||
return await api.get<Model.AlarmResponse>(API_GET_ALARM, {
|
||||
params: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryConfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||
return await api.post(API_MANAGER_ALARM, body);
|
||||
}
|
||||
export async function queryrUnconfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||
return await api.delete(API_MANAGER_ALARM, { data: body });
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { api } from "@/config";
|
||||
import { API_PATH_LOGIN } from "@/constants";
|
||||
import { API_PATH_GET_PROFILE, API_PATH_LOGIN } from "@/constants";
|
||||
|
||||
export async function queryLogin(body: Model.LoginRequestBody) {
|
||||
return api.post<Model.LoginResponse>(API_PATH_LOGIN, body);
|
||||
}
|
||||
|
||||
export async function queryProfile() {
|
||||
return api.get<Model.ProfileResponse>(API_PATH_GET_PROFILE);
|
||||
}
|
||||
|
||||
80
controller/CrewController.ts
Normal file
80
controller/CrewController.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,43 +1,11 @@
|
||||
import { api } from "@/config";
|
||||
import {
|
||||
API_GET_ALARMS,
|
||||
API_GET_GPS,
|
||||
API_GET_ALL_SHIP,
|
||||
API_GET_PHOTO,
|
||||
API_GET_SHIP_GROUPS,
|
||||
API_GET_SHIP_TYPES,
|
||||
API_PATH_ENTITIES,
|
||||
API_PATH_SEARCH_THINGS,
|
||||
API_PATH_SHIP_TRACK_POINTS,
|
||||
API_SOS,
|
||||
} from "@/constants";
|
||||
import { transformEntityResponse } from "@/utils/tranform";
|
||||
|
||||
export async function queryGpsData() {
|
||||
return api.get<Model.GPSResponse>(API_GET_GPS);
|
||||
}
|
||||
|
||||
export async function queryAlarm() {
|
||||
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
|
||||
}
|
||||
|
||||
export async function queryTrackPoints() {
|
||||
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
||||
}
|
||||
|
||||
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
||||
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
||||
return response.data.map(transformEntityResponse);
|
||||
}
|
||||
|
||||
export async function queryGetSos() {
|
||||
return await api.get<Model.SosResponse>(API_SOS);
|
||||
}
|
||||
export async function queryDeleteSos() {
|
||||
return await api.delete<Model.SosResponse>(API_SOS);
|
||||
}
|
||||
|
||||
export async function querySendSosMessage(message: string) {
|
||||
return await api.put<Model.SosRequest>(API_SOS, { message });
|
||||
}
|
||||
|
||||
export async function querySearchThings(body: Model.SearchThingBody) {
|
||||
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
|
||||
@@ -50,3 +18,23 @@ export async function queryShipTypes() {
|
||||
export async function queryShipGroups() {
|
||||
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
|
||||
}
|
||||
|
||||
export async function queryAllShips() {
|
||||
return await api.get<Model.ShipResponse>(API_GET_ALL_SHIP);
|
||||
}
|
||||
|
||||
export async function queryShipsImage(ship_id: string) {
|
||||
return await api.get(`${API_GET_PHOTO}/ship/${ship_id}/main`, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryCreateShip(body: Model.ShipBodyRequest) {
|
||||
return await api.post(`${API_GET_ALL_SHIP}`, body);
|
||||
}
|
||||
export async function queryUpdateShip(
|
||||
shipId: string,
|
||||
body: Model.ShipBodyRequest
|
||||
) {
|
||||
return await api.put(`${API_GET_ALL_SHIP}/${shipId}`, body);
|
||||
}
|
||||
|
||||
25
controller/GroupController.ts
Normal file
25
controller/GroupController.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from "@/config";
|
||||
import { UID } from "@/constants";
|
||||
import { getStorageItem } from "@/utils/storage";
|
||||
|
||||
export async function queryUserGroup() {
|
||||
const user_id = await getStorageItem(UID);
|
||||
return api.get<Model.GroupResponse>(`/api/members/${user_id}/groups`);
|
||||
}
|
||||
|
||||
export async function queryChilrentOfGroups(
|
||||
group_id: string,
|
||||
level: number = 5,
|
||||
isTree: boolean = false
|
||||
) {
|
||||
// ensure proper query param values when not provided by caller
|
||||
const lvl = typeof level === "number" ? level : 5;
|
||||
const tree = !!isTree;
|
||||
const params = {
|
||||
level: lvl,
|
||||
tree: tree,
|
||||
};
|
||||
return api.get<Model.GroupResponse>(`/api/groups/${group_id}/children`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
6
controller/PortController.ts
Normal file
6
controller/PortController.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/config";
|
||||
import { API_GET_ALL_PORT } from "@/constants";
|
||||
|
||||
export async function queryPorts(body?: Model.SearchThingBody) {
|
||||
return api.post<Model.PortResponse>(API_GET_ALL_PORT, body);
|
||||
}
|
||||
@@ -6,12 +6,22 @@ import {
|
||||
API_UPDATE_FISHING_LOGS,
|
||||
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}`);
|
||||
}
|
||||
@@ -31,3 +41,19 @@ export async function queryUpdateFishingLogs(body: Model.FishingLog) {
|
||||
export async function queryTripsList(body: Model.TripListBody) {
|
||||
return api.post(API_POST_TRIPSLIST, body);
|
||||
}
|
||||
|
||||
export async function createTrip(thingId: string, body: Model.TripAPIBody) {
|
||||
return api.post<Model.Trip>(`${API_POST_TRIP}/${thingId}`, body);
|
||||
}
|
||||
|
||||
export async function updateTrip(tripId: string, body: Model.TripAPIBody) {
|
||||
return api.put<Model.Trip>(`${API_PUT_TRIP}/${tripId}`, body);
|
||||
}
|
||||
|
||||
export async function tripApproveRequest(tripId: string) {
|
||||
return api.put<Model.Trip>(`${API_TRIP_APPROVE_REQUEST}/${tripId}`);
|
||||
}
|
||||
|
||||
export async function tripCancelRequest(tripId: string) {
|
||||
return api.put<Model.Trip>(`${API_TRIP_CANCEL_REQUEST}/${tripId}`);
|
||||
}
|
||||
|
||||
25
controller/TripCrewController.ts
Normal file
25
controller/TripCrewController.ts
Normal 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}`);
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import * as AlarmController from "./AlarmController";
|
||||
import * as AuthController from "./AuthController";
|
||||
import * as DeviceController from "./DeviceController";
|
||||
import * as FishController from "./FishController";
|
||||
import * as MapController from "./MapController";
|
||||
import * as PortController from "./PortController";
|
||||
import * as TripController from "./TripController";
|
||||
export { AuthController, DeviceController, MapController, TripController };
|
||||
|
||||
export {
|
||||
AlarmController,
|
||||
AuthController,
|
||||
DeviceController,
|
||||
FishController,
|
||||
MapController,
|
||||
PortController,
|
||||
TripController,
|
||||
};
|
||||
|
||||
249
controller/typings.d.ts
vendored
249
controller/typings.d.ts
vendored
@@ -9,6 +9,20 @@ declare namespace Model {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
id?: string;
|
||||
email?: string;
|
||||
metadata?: ProfileMetadata;
|
||||
}
|
||||
|
||||
interface ProfileMetadata {
|
||||
frontend_thing_id?: string;
|
||||
frontend_thing_key?: string;
|
||||
full_name?: string;
|
||||
phone_number?: string;
|
||||
user_type?: string;
|
||||
}
|
||||
|
||||
interface GPSResponse {
|
||||
lat: number;
|
||||
lon: number;
|
||||
@@ -17,41 +31,6 @@ declare namespace Model {
|
||||
fishing: boolean;
|
||||
t: number;
|
||||
}
|
||||
interface Alarm {
|
||||
name: string;
|
||||
t: number; // timestamp (epoch seconds)
|
||||
level: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface AlarmResponse {
|
||||
alarms: Alarm[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface ShipTrackPoint {
|
||||
time: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
s: number;
|
||||
h: number;
|
||||
}
|
||||
interface EntityResponse {
|
||||
id: string;
|
||||
v: number;
|
||||
vs: string;
|
||||
t: number;
|
||||
type: string;
|
||||
}
|
||||
interface TransformedEntity {
|
||||
id: string;
|
||||
value: number;
|
||||
valueString: string;
|
||||
time: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Banzones
|
||||
// Banzone
|
||||
interface Zone {
|
||||
id?: string;
|
||||
@@ -175,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;
|
||||
@@ -221,6 +231,27 @@ declare namespace Model {
|
||||
status: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// API body interface for creating a new trip
|
||||
interface TripAPIBody {
|
||||
name: string;
|
||||
departure_time: string; // ISO string
|
||||
departure_port_id: number;
|
||||
arrival_time: string; // ISO string
|
||||
arrival_port_id: number;
|
||||
fishing_ground_codes: number[];
|
||||
fishing_gears: Array<{
|
||||
name: string;
|
||||
number: string;
|
||||
}>;
|
||||
trip_cost: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
unit: string;
|
||||
cost_per_unit: number;
|
||||
total_cost: number;
|
||||
}>;
|
||||
}
|
||||
//Fish
|
||||
interface FishSpeciesResponse {
|
||||
id: number;
|
||||
@@ -332,4 +363,152 @@ declare namespace Model {
|
||||
owner_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AlarmPayload {
|
||||
offset: number;
|
||||
limit: number;
|
||||
order?: string;
|
||||
dir?: "asc" | "desc";
|
||||
name?: string;
|
||||
level?: number;
|
||||
thing_id?: string;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
interface AlarmResponse {
|
||||
total?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
dir?: string;
|
||||
alarms?: Alarm[];
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
name?: string;
|
||||
time?: number;
|
||||
level?: number;
|
||||
id?: string;
|
||||
confirmed?: boolean;
|
||||
confirmed_email?: string;
|
||||
confirmed_time?: number;
|
||||
confirmed_desc?: string;
|
||||
thing_id?: string;
|
||||
thing_name?: string;
|
||||
thing_type?: ThingType;
|
||||
}
|
||||
|
||||
interface AlarmConfirmRequest {
|
||||
id: string;
|
||||
description?: string;
|
||||
thing_id: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface ShipBodyRequest {
|
||||
name?: string;
|
||||
reg_number?: string;
|
||||
imo_number?: string;
|
||||
mmsi_number?: string;
|
||||
thing_id?: string;
|
||||
ship_type?: number;
|
||||
owner_id?: string;
|
||||
home_port?: number;
|
||||
ship_length?: number;
|
||||
ship_power?: number;
|
||||
ship_group_id?: string;
|
||||
fishing_license_number?: string;
|
||||
fishing_license_expiry_date?: Date;
|
||||
}
|
||||
interface ShipResponse {
|
||||
ships?: Ship[];
|
||||
}
|
||||
|
||||
interface Ship {
|
||||
id?: string;
|
||||
thing_id?: string;
|
||||
owner_id?: string;
|
||||
name?: string;
|
||||
ship_type?: number;
|
||||
home_port?: number;
|
||||
ship_length?: number;
|
||||
ship_power?: number;
|
||||
reg_number?: string;
|
||||
imo_number?: string;
|
||||
mmsi_number?: string;
|
||||
fishing_license_number?: string;
|
||||
fishing_license_expiry_date?: Date;
|
||||
province_code?: string;
|
||||
ship_group_id?: string;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
}
|
||||
|
||||
interface PortResponse {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
ports?: Port[];
|
||||
}
|
||||
|
||||
interface Port {
|
||||
id?: number;
|
||||
name?: string;
|
||||
type?: Type;
|
||||
classification?: Classification;
|
||||
position_point?: string;
|
||||
has_origin_confirm?: boolean;
|
||||
province_code?: string;
|
||||
updated_at?: Date;
|
||||
is_deleted?: boolean;
|
||||
}
|
||||
|
||||
enum Classification {
|
||||
ChưaXácĐịnh = "Chưa xác định",
|
||||
I = "I",
|
||||
Ii = "II",
|
||||
}
|
||||
|
||||
enum Type {
|
||||
Fishing = "fishing",
|
||||
}
|
||||
|
||||
// Groups
|
||||
interface GroupResponse {
|
||||
total?: number;
|
||||
level?: number;
|
||||
name?: string;
|
||||
groups?: Group[];
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id?: string;
|
||||
name?: string;
|
||||
owner_id?: string;
|
||||
description?: string;
|
||||
metadata?: GroupMetadata;
|
||||
level?: number;
|
||||
path?: string;
|
||||
children?: Child[];
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
}
|
||||
|
||||
interface Child {
|
||||
id?: string;
|
||||
name?: string;
|
||||
owner_id?: string;
|
||||
parent_id?: string;
|
||||
description?: string;
|
||||
metadata?: GroupMetadata;
|
||||
level?: number;
|
||||
path?: string;
|
||||
children?: Child[];
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
}
|
||||
|
||||
interface GroupMetadata {
|
||||
code?: string;
|
||||
short_name?: string;
|
||||
}
|
||||
}
|
||||
|
||||
133
locales/en.json
133
locales/en.json
@@ -3,6 +3,7 @@
|
||||
"app_name": "Sea Gateway",
|
||||
"footer_text": "Product of Mobifone v1.0",
|
||||
"ok": "OK",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"done": "Done",
|
||||
"save": "Save",
|
||||
@@ -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",
|
||||
@@ -165,6 +180,114 @@
|
||||
"success": "Data filled from last trip",
|
||||
"error": "Unable to fetch trip data",
|
||||
"noData": "No previous trip data available"
|
||||
},
|
||||
"validation": {
|
||||
"shipRequired": "Please select a ship before creating the trip",
|
||||
"datesRequired": "Please select departure and arrival dates",
|
||||
"tripNameRequired": "Please enter a trip name",
|
||||
"startDateNotInPast": "Departure time cannot be in the past",
|
||||
"endDateAfterStart": "Arrival time must be after departure time"
|
||||
},
|
||||
"createTripSuccess": "Trip created successfully!",
|
||||
"createTripError": "Unable to create trip. Please try again.",
|
||||
"tripAlreadyExistsError": "There is an ongoing trip that has not been completed. Please complete the current trip before creating a new one.",
|
||||
"editTrip": "Edit Trip",
|
||||
"viewTrip": "Trip Details",
|
||||
"saveChanges": "Save Changes",
|
||||
"updateTripSuccess": "Trip updated successfully!",
|
||||
"updateTripError": "Unable to update trip. Please try again.",
|
||||
"cancelTripConfirmTitle": "Cancel Request Confirmation",
|
||||
"cancelTripConfirmMessage": "Are you sure you want to cancel the approval request? The trip will be reset to initial status.",
|
||||
"cancelTripError": "Unable to cancel request. Please try again.",
|
||||
"noCrewErrorTitle": "Cannot Send for Approval",
|
||||
"noCrewErrorMessage": "This trip has no crew members. Please add crew members before sending for approval.",
|
||||
"sendApprovalError": "Unable to send approval request. Please try again.",
|
||||
"crew": {
|
||||
"title": "Crew Members",
|
||||
"loading": "Loading crew members...",
|
||||
"fetchError": "Unable to load crew members. Please try again.",
|
||||
"noCrewMembers": "No crew members in this trip yet",
|
||||
"totalMembers": "Total: {{count}} members",
|
||||
"member": "Crew Member",
|
||||
"phone": "Phone",
|
||||
"personalId": "ID Number",
|
||||
"joinedAt": "Joined Date",
|
||||
"leftAt": "Left Date",
|
||||
"note": "Note",
|
||||
"deleteConfirmTitle": "Delete Crew Member",
|
||||
"deleteConfirmMessage": "Are you sure you want to remove {{name}} from this trip?",
|
||||
"deleteSuccess": "Crew member removed successfully",
|
||||
"roles": {
|
||||
"captain": "Captain",
|
||||
"crew": "Crew",
|
||||
"engineer": "Engineer",
|
||||
"cook": "Cook"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "Add Crew Member",
|
||||
"editTitle": "Edit Crew Member",
|
||||
"name": "Full Name",
|
||||
"namePlaceholder": "Enter full name",
|
||||
"nameRequired": "Please enter name",
|
||||
"personalIdPlaceholder": "Enter ID number",
|
||||
"personalIdRequired": "Please enter ID number",
|
||||
"phonePlaceholder": "Enter phone number",
|
||||
"role": "Role",
|
||||
"address": "Address",
|
||||
"addressPlaceholder": "Enter address",
|
||||
"notePlaceholder": "Enter note (optional)",
|
||||
"saveError": "Unable to save. Please try again.",
|
||||
"searchHint": "Enter ID number, system will auto-search",
|
||||
"waitingSearch": "Waiting to search...",
|
||||
"searching": "Searching...",
|
||||
"crewFound": "Crew member found, data has been auto-filled",
|
||||
"crewNotFound": "Crew member not found, please enter information to create new",
|
||||
"crewAlreadyExists": "This crew member is already in the trip"
|
||||
}
|
||||
},
|
||||
"tripDetail": {
|
||||
"title": "Trip Details",
|
||||
"notFound": "Trip information not found",
|
||||
"basicInfo": "Basic Information",
|
||||
"shipName": "Ship Name",
|
||||
"shipCode": "Ship Code",
|
||||
"departureTime": "Departure Time",
|
||||
"arrivalTime": "Arrival Time",
|
||||
"departurePort": "Departure Port",
|
||||
"arrivalPort": "Arrival Port",
|
||||
"fishingGrounds": "Fishing Grounds",
|
||||
"alerts": "Alert List",
|
||||
"noAlerts": "No alerts",
|
||||
"unknownAlert": "Unknown alert",
|
||||
"confirmed": "Confirmed",
|
||||
"costs": "Trip Costs",
|
||||
"noCosts": "No costs",
|
||||
"unknownCost": "Unknown cost",
|
||||
"totalCost": "Total Cost",
|
||||
"gears": "Fishing Gear List",
|
||||
"noGears": "No fishing gear",
|
||||
"unknownGear": "Unknown gear",
|
||||
"quantity": "Quantity",
|
||||
"crew": "Crew List",
|
||||
"noCrew": "No crew",
|
||||
"unknownCrew": "Unknown crew member",
|
||||
"roleCaptain": "Captain",
|
||||
"roleCrew": "Crew",
|
||||
"roleEngineer": "Engineer",
|
||||
"fishingLogs": "Fishing Log List",
|
||||
"noFishingLogs": "No fishing logs",
|
||||
"startTime": "Start",
|
||||
"endTime": "End",
|
||||
"startLocation": "Start Location",
|
||||
"haulLocation": "Haul Location",
|
||||
"catchInfo": "Catch Info",
|
||||
"species": "species",
|
||||
"unknownFish": "Unknown fish",
|
||||
"more": "more species",
|
||||
"logStatusProcessing": "Processing",
|
||||
"logStatusSuccess": "Complete",
|
||||
"logStatusCancelled": "Cancelled",
|
||||
"logStatusUnknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"trip": {
|
||||
|
||||
135
locales/vi.json
135
locales/vi.json
@@ -3,6 +3,7 @@
|
||||
"app_name": "Hệ thống giám sát tàu cá",
|
||||
"footer_text": "Sản phẩm của Mobifone v1.0",
|
||||
"ok": "OK",
|
||||
"confirm": "Xác nhận",
|
||||
"cancel": "Hủy",
|
||||
"done": "Xong",
|
||||
"save": "Lưu",
|
||||
@@ -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",
|
||||
@@ -165,6 +180,114 @@
|
||||
"success": "Đã điền dữ liệu từ chuyến đi cuối cùng",
|
||||
"error": "Không thể lấy dữ liệu chuyến đi",
|
||||
"noData": "Không có dữ liệu chuyến đi trước đó"
|
||||
},
|
||||
"validation": {
|
||||
"shipRequired": "Vui lòng chọn tàu trước khi tạo chuyến đi",
|
||||
"datesRequired": "Vui lòng chọn ngày khởi hành và ngày kết thúc",
|
||||
"tripNameRequired": "Vui lòng nhập tên chuyến đi",
|
||||
"startDateNotInPast": "Thời điểm khởi hành không được ở quá khứ",
|
||||
"endDateAfterStart": "Thời điểm kết thúc phải sau thời điểm khởi hành"
|
||||
},
|
||||
"createTripSuccess": "Tạo chuyến đi thành công!",
|
||||
"createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại.",
|
||||
"tripAlreadyExistsError": "Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới.",
|
||||
"editTrip": "Chỉnh sửa chuyến đi",
|
||||
"viewTrip": "Chi tiết chuyến đi",
|
||||
"saveChanges": "Lưu thay đổi",
|
||||
"updateTripSuccess": "Cập nhật chuyến đi thành công!",
|
||||
"updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.",
|
||||
"cancelTripConfirmTitle": "Xác nhận hủy yêu cầu",
|
||||
"cancelTripConfirmMessage": "Bạn có chắc chắn muốn hủy yêu cầu phê duyệt? Chuyến đi sẽ trở về trạng thái đã khởi tạo.",
|
||||
"cancelTripError": "Không thể hủy yêu cầu. Vui lòng thử lại.",
|
||||
"noCrewErrorTitle": "Không thể gửi phê duyệt",
|
||||
"noCrewErrorMessage": "Chuyến đi chưa có thuyền viên. Vui lòng thêm thuyền viên trước khi gửi phê duyệt.",
|
||||
"sendApprovalError": "Không thể gửi yêu cầu phê duyệt. Vui lòng thử lại.",
|
||||
"crew": {
|
||||
"title": "Danh sách thuyền viên",
|
||||
"loading": "Đang tải danh sách thuyền viên...",
|
||||
"fetchError": "Không thể tải danh sách thuyền viên. Vui lòng thử lại.",
|
||||
"noCrewMembers": "Chưa có thuyền viên trong chuyến đi này",
|
||||
"totalMembers": "Tổng cộng: {{count}} thuyền viên",
|
||||
"member": "Thuyền viên",
|
||||
"phone": "Điện thoại",
|
||||
"personalId": "CMND/CCCD",
|
||||
"joinedAt": "Ngày tham gia",
|
||||
"leftAt": "Ngày rời đi",
|
||||
"note": "Ghi chú",
|
||||
"deleteConfirmTitle": "Xóa thuyền viên",
|
||||
"deleteConfirmMessage": "Bạn có chắc chắn muốn xóa {{name}} khỏi chuyến đi này?",
|
||||
"deleteSuccess": "Đã xóa thuyền viên thành công",
|
||||
"roles": {
|
||||
"captain": "Thuyền trưởng",
|
||||
"crew": "Thuyền viên",
|
||||
"engineer": "Kỹ sư",
|
||||
"cook": "Đầu bếp"
|
||||
},
|
||||
"form": {
|
||||
"addTitle": "Thêm thuyền viên",
|
||||
"editTitle": "Chỉnh sửa thuyền viên",
|
||||
"name": "Họ và tên",
|
||||
"namePlaceholder": "Nhập họ và tên",
|
||||
"nameRequired": "Vui lòng nhập họ tên",
|
||||
"personalIdPlaceholder": "Nhập số CMND/CCCD",
|
||||
"personalIdRequired": "Vui lòng nhập số CMND/CCCD",
|
||||
"phonePlaceholder": "Nhập số điện thoại",
|
||||
"role": "Chức vụ",
|
||||
"address": "Địa chỉ",
|
||||
"addressPlaceholder": "Nhập địa chỉ",
|
||||
"notePlaceholder": "Nhập ghi chú (nếu có)",
|
||||
"saveError": "Không thể lưu thông tin. Vui lòng thử lại.",
|
||||
"searchHint": "Nhập số định danh, hệ thống sẽ tự động tìm kiếm",
|
||||
"waitingSearch": "Đang chờ tìm kiếm...",
|
||||
"searching": "Đang tìm kiếm...",
|
||||
"crewFound": "Đã tìm thấy thuyền viên, dữ liệu đã được điền tự động",
|
||||
"crewNotFound": "Không tìm thấy thuyền viên, vui lòng nhập thông tin để tạo mới",
|
||||
"crewAlreadyExists": "Thuyền viên này đã có trong chuyến đi"
|
||||
}
|
||||
},
|
||||
"tripDetail": {
|
||||
"title": "Chi tiết chuyến đi",
|
||||
"notFound": "Không tìm thấy thông tin chuyến đi",
|
||||
"basicInfo": "Thông tin cơ bản",
|
||||
"shipName": "Tên tàu",
|
||||
"shipCode": "Mã tàu",
|
||||
"departureTime": "Thời gian khởi hành",
|
||||
"arrivalTime": "Thời gian về bến",
|
||||
"departurePort": "Cảng khởi hành",
|
||||
"arrivalPort": "Cảng cập bến",
|
||||
"fishingGrounds": "Ngư trường",
|
||||
"alerts": "Danh sách cảnh báo",
|
||||
"noAlerts": "Không có cảnh báo",
|
||||
"unknownAlert": "Cảnh báo không xác định",
|
||||
"confirmed": "Đã xác nhận",
|
||||
"costs": "Chi phí chuyến đi",
|
||||
"noCosts": "Chưa có chi phí",
|
||||
"unknownCost": "Chi phí không xác định",
|
||||
"totalCost": "Tổng chi phí",
|
||||
"gears": "Danh sách ngư cụ",
|
||||
"noGears": "Chưa có ngư cụ",
|
||||
"unknownGear": "Ngư cụ không xác định",
|
||||
"quantity": "Số lượng",
|
||||
"crew": "Danh sách thuyền viên",
|
||||
"noCrew": "Chưa có thuyền viên",
|
||||
"unknownCrew": "Thuyền viên không xác định",
|
||||
"roleCaptain": "Thuyền trưởng",
|
||||
"roleCrew": "Thuyền viên",
|
||||
"roleEngineer": "Kỹ sư",
|
||||
"fishingLogs": "Danh sách mẻ lưới",
|
||||
"noFishingLogs": "Chưa có mẻ lưới",
|
||||
"startTime": "Bắt đầu",
|
||||
"endTime": "Kết thúc",
|
||||
"startLocation": "Vị trí thả",
|
||||
"haulLocation": "Vị trí kéo",
|
||||
"catchInfo": "Sản lượng",
|
||||
"species": "loài",
|
||||
"unknownFish": "Cá không xác định",
|
||||
"more": "loài khác",
|
||||
"logStatusProcessing": "Đang đánh bắt",
|
||||
"logStatusSuccess": "Đã kết thúc",
|
||||
"logStatusCancelled": "Đã hủy",
|
||||
"logStatusUnknown": "Không xác định"
|
||||
}
|
||||
},
|
||||
"trip": {
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"axios": "^1.13.1",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~54.0.20",
|
||||
@@ -27,7 +28,7 @@
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"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,21 +8547,25 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.10.tgz",
|
||||
"integrity": "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ==",
|
||||
"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": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
"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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-keep-awake": {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"axios": "^1.13.1",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"base64-js": "^1.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~54.0.20",
|
||||
@@ -30,7 +31,7 @@
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"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",
|
||||
|
||||
@@ -1,139 +1,20 @@
|
||||
import {
|
||||
AUTO_REFRESH_INTERVAL,
|
||||
EVENT_ALARM_DATA,
|
||||
EVENT_BANZONE_DATA,
|
||||
EVENT_ENTITY_DATA,
|
||||
EVENT_GPS_DATA,
|
||||
EVENT_SEARCH_THINGS,
|
||||
EVENT_TRACK_POINTS_DATA,
|
||||
} from "@/constants";
|
||||
import {
|
||||
queryAlarm,
|
||||
queryEntities,
|
||||
queryGpsData,
|
||||
querySearchThings,
|
||||
queryTrackPoints,
|
||||
} from "@/controller/DeviceController";
|
||||
import { querySearchThings } from "@/controller/DeviceController";
|
||||
import { queryBanzones } from "@/controller/MapController";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
|
||||
const intervals: {
|
||||
gps: ReturnType<typeof setInterval> | null;
|
||||
alarm: ReturnType<typeof setInterval> | null;
|
||||
entities: ReturnType<typeof setInterval> | null;
|
||||
trackPoints: ReturnType<typeof setInterval> | null;
|
||||
banzones: ReturnType<typeof setInterval> | null;
|
||||
searchThings: ReturnType<typeof setInterval> | null;
|
||||
} = {
|
||||
gps: null,
|
||||
alarm: null,
|
||||
entities: null,
|
||||
trackPoints: null,
|
||||
banzones: null,
|
||||
searchThings: null,
|
||||
};
|
||||
|
||||
export function getGpsEventBus() {
|
||||
if (intervals.gps) return;
|
||||
// console.log("Starting GPS poller");
|
||||
|
||||
const getGpsData = async () => {
|
||||
try {
|
||||
// console.log("GPS: fetching data...");
|
||||
const resp = await queryGpsData();
|
||||
if (resp && resp.data) {
|
||||
// console.log("GPS: emitting data", resp.data);
|
||||
eventBus.emit(EVENT_GPS_DATA, resp.data);
|
||||
} else {
|
||||
console.log("GPS: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("GPS: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately once, then schedule
|
||||
getGpsData();
|
||||
intervals.gps = setInterval(() => {
|
||||
getGpsData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getAlarmEventBus() {
|
||||
if (intervals.alarm) return;
|
||||
// console.log("Goi ham get Alarm");
|
||||
const getAlarmData = async () => {
|
||||
try {
|
||||
// console.log("Alarm: fetching data...");
|
||||
const resp = await queryAlarm();
|
||||
if (resp && resp.data) {
|
||||
// console.log(
|
||||
// "Alarm: emitting data",
|
||||
// resp.data?.alarms?.length ?? resp.data
|
||||
// );
|
||||
eventBus.emit(EVENT_ALARM_DATA, resp.data);
|
||||
} else {
|
||||
console.log("Alarm: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Alarm: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getAlarmData();
|
||||
intervals.alarm = setInterval(() => {
|
||||
getAlarmData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getEntitiesEventBus() {
|
||||
if (intervals.entities) return;
|
||||
// console.log("Goi ham get Entities");
|
||||
const getEntitiesData = async () => {
|
||||
try {
|
||||
// console.log("Entities: fetching data...");
|
||||
const resp = await queryEntities();
|
||||
if (resp && resp.length > 0) {
|
||||
// console.log("Entities: emitting", resp.length);
|
||||
eventBus.emit(EVENT_ENTITY_DATA, resp);
|
||||
} else {
|
||||
console.log("Entities: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Entities: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getEntitiesData();
|
||||
intervals.entities = setInterval(() => {
|
||||
getEntitiesData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getTrackPointsEventBus() {
|
||||
if (intervals.trackPoints) return;
|
||||
// console.log("Goi ham get Track Points");
|
||||
const getTrackPointsData = async () => {
|
||||
try {
|
||||
// console.log("TrackPoints: fetching data...");
|
||||
const resp = await queryTrackPoints();
|
||||
if (resp && resp.data && resp.data.length > 0) {
|
||||
// console.log("TrackPoints: emitting", resp.data.length);
|
||||
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
|
||||
} else {
|
||||
console.log("TrackPoints: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("TrackPoints: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getTrackPointsData();
|
||||
intervals.trackPoints = setInterval(() => {
|
||||
getTrackPointsData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getBanzonesEventBus() {
|
||||
if (intervals.banzones) return;
|
||||
const getBanzonesData = async () => {
|
||||
@@ -199,9 +80,5 @@ export function stopEvents() {
|
||||
}
|
||||
|
||||
export function startEvents() {
|
||||
getGpsEventBus();
|
||||
getAlarmEventBus();
|
||||
getEntitiesEventBus();
|
||||
getTrackPointsEventBus();
|
||||
getBanzonesEventBus();
|
||||
}
|
||||
|
||||
41
state/use-group.ts
Normal file
41
state/use-group.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
queryChilrentOfGroups,
|
||||
queryUserGroup,
|
||||
} from "@/controller/GroupController";
|
||||
import { create } from "zustand";
|
||||
|
||||
type Groups = {
|
||||
groups: Model.GroupResponse | null;
|
||||
childrenOfGroups?: Model.GroupResponse | null;
|
||||
getChildrenOfGroups: (group_id: string) => Promise<void>;
|
||||
getUserGroups: () => Promise<void>;
|
||||
error: string | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const useGroup = create<Groups>((set) => ({
|
||||
groups: null,
|
||||
childrenOfGroups: null,
|
||||
getUserGroups: async () => {
|
||||
try {
|
||||
const response = await queryUserGroup();
|
||||
set({ groups: response.data, loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetch Port: ", error);
|
||||
set({ error: "Failed to fetch Port data", loading: false });
|
||||
set({ groups: null });
|
||||
}
|
||||
},
|
||||
getChildrenOfGroups: async (group_id: string) => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
const response = await queryChilrentOfGroups(group_id);
|
||||
set({ childrenOfGroups: response.data, loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetching children of groups: ", error);
|
||||
set({ error: "Failed to fetch children of groups", loading: false });
|
||||
set({ childrenOfGroups: null });
|
||||
}
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
32
state/use-ports.ts
Normal file
32
state/use-ports.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { queryPorts } from "@/controller/PortController";
|
||||
import { create } from "zustand";
|
||||
|
||||
type Ports = {
|
||||
ports: Model.PortResponse | null;
|
||||
getPorts: () => Promise<void>;
|
||||
error: string | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const usePort = create<Ports>((set) => ({
|
||||
ports: null,
|
||||
getPorts: async (body?: Model.SearchThingBody) => {
|
||||
try {
|
||||
if (body === undefined) {
|
||||
body = {
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
dir: "asc",
|
||||
order: "id",
|
||||
};
|
||||
}
|
||||
const response = await queryPorts(body);
|
||||
set({ ports: response.data, loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetch Port: ", error);
|
||||
set({ error: "Failed to fetch Port data", loading: false });
|
||||
set({ ports: null });
|
||||
}
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
24
state/use-ship-groups.ts
Normal file
24
state/use-ship-groups.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { queryShipGroups } from "@/controller/DeviceController";
|
||||
import { create } from "zustand";
|
||||
|
||||
type ShipGroups = {
|
||||
shipGroups: Model.ShipGroup[] | null;
|
||||
getShipGroups: () => Promise<void>;
|
||||
error: string | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const useShipGroups = create<ShipGroups>((set) => ({
|
||||
shipGroups: null,
|
||||
getShipGroups: async () => {
|
||||
try {
|
||||
const response = await queryShipGroups();
|
||||
set({ shipGroups: response.data, loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetch Port: ", error);
|
||||
set({ error: "Failed to fetch Port data", loading: false });
|
||||
set({ shipGroups: null });
|
||||
}
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
24
state/use-ship.ts
Normal file
24
state/use-ship.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { queryAllShips } from "@/controller/DeviceController";
|
||||
import { create } from "zustand";
|
||||
|
||||
type Ship = {
|
||||
ships: Model.Ship[] | null;
|
||||
getShip: () => Promise<void>;
|
||||
error: string | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const useShip = create<Ship>((set) => ({
|
||||
ships: null,
|
||||
getShip: async () => {
|
||||
try {
|
||||
const response = await queryAllShips();
|
||||
set({ ships: response.data?.ships, loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetch Ship: ", error);
|
||||
set({ error: "Failed to fetch Ship data", loading: false });
|
||||
set({ ships: null });
|
||||
}
|
||||
},
|
||||
error: null,
|
||||
}));
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ROLE, UID } from "@/constants";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
export async function setStorageItem(
|
||||
@@ -28,3 +29,20 @@ export async function removeStorageItem(key: string): Promise<void> {
|
||||
console.error("Error removing storage item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearUserStorage() {
|
||||
try {
|
||||
await AsyncStorage.removeItem(UID);
|
||||
await AsyncStorage.removeItem(ROLE);
|
||||
} catch (error) {
|
||||
console.error("Error with clear user Storage: ", error);
|
||||
}
|
||||
}
|
||||
export async function addUserStorage(userId: string, role: string) {
|
||||
try {
|
||||
setStorageItem(UID, userId);
|
||||
setStorageItem(ROLE, role);
|
||||
} catch (error) {
|
||||
console.error("Error with set user Storage: ", error);
|
||||
}
|
||||
}
|
||||
|
||||
94
utils/tripDataConverters.ts
Normal file
94
utils/tripDataConverters.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user