Cập nhật tab Nhật ký ( CRUD chuyến đi, CRUD thuyền viên trong chuyến đi )

This commit is contained in:
2025-12-29 15:56:47 +07:00
parent 190e44b09e
commit 871360af49
24 changed files with 1451 additions and 407 deletions

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform, Platform,
StyleSheet, StyleSheet,
@@ -20,6 +21,7 @@ import { useTripsList } from "@/state/use-tripslist";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
export default function diary() { export default function diary() {
const { t } = useI18n(); const { t } = useI18n();
@@ -42,10 +44,6 @@ export default function diary() {
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
// Refs to prevent duplicate API calls from React Compiler/Strict Mode
const hasInitializedThings = useRef(false);
const hasInitializedTrips = useRef(false);
// Body call API things (đang fix cứng) // Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = { const payloadThings: Model.SearchThingBody = {
offset: 0, offset: 0,
@@ -57,13 +55,21 @@ export default function diary() {
}, },
}; };
// Gọi API things // Gọi API things nếu chưa có dữ liệu
const { getThings } = useThings(); const { things, getThings } = useThings();
useEffect(() => { useEffect(() => {
if (hasInitializedThings.current) return; if (!things) {
hasInitializedThings.current = true; getThings(payloadThings);
getThings(payloadThings); }
}, []); }, [things, getThings]);
// Gọi API ships nếu chưa có dữ liệu
const { ships, getShip } = useShip();
useEffect(() => {
if (!ships) {
getShip();
}
}, [ships, getShip]);
// State cho payload trips // State cho payload trips
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({ const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
@@ -88,9 +94,8 @@ export default function diary() {
// Gọi API trips lần đầu // Gọi API trips lần đầu
useEffect(() => { useEffect(() => {
if (hasInitializedTrips.current) return; if (!isInitialLoad.current) return;
hasInitializedTrips.current = true; isInitialLoad.current = false;
isInitialLoad.current = true;
setAllTrips([]); setAllTrips([]);
setHasMore(true); setHasMore(true);
getTripsList(payloadTrips); getTripsList(payloadTrips);
@@ -182,23 +187,12 @@ export default function diary() {
getTripsList(updatedPayload); getTripsList(updatedPayload);
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]); }, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
// const handleTripPress = (tripId: string) => {
// // TODO: Navigate to trip detail
// console.log("Trip pressed:", tripId);
// };
const handleViewTrip = (tripId: string) => { const handleViewTrip = (tripId: string) => {
// Navigate to trip detail page instead of opening modal // Navigate to trip detail page - chỉ truyền tripId
const tripToView = allTrips.find((trip) => trip.id === tripId); router.push({
if (tripToView) { pathname: "/trip-detail",
router.push({ params: { tripId },
pathname: "/trip-detail", });
params: {
tripId: tripToView.id,
tripData: JSON.stringify(tripToView),
},
});
}
}; };
const handleEditTrip = (tripId: string) => { const handleEditTrip = (tripId: string) => {
@@ -215,20 +209,107 @@ export default function diary() {
if (trip) { if (trip) {
router.push({ router.push({
pathname: "/trip-crew", pathname: "/trip-crew",
params: { tripId: trip.id, tripName: trip.name || "" }, params: {
tripId: trip.id,
tripName: trip.name || "",
tripStatus: String(trip.trip_status ?? ""), // trip_status là số
},
}); });
} }
}; };
const handleSendTrip = (tripId: string) => { const handleSendTrip = useCallback(
console.log("Send trip:", tripId); async (tripId: string) => {
// TODO: Send trip for approval try {
}; // Import dynamically để tránh circular dependency
const { tripApproveRequest } = await import(
"@/controller/TripController"
);
const handleDeleteTrip = (tripId: string) => { // Gọi API gửi yêu cầu phê duyệt
console.log("Delete trip:", tripId); await tripApproveRequest(tripId);
// TODO: Show confirmation dialog and delete trip console.log("✅ Send trip for approval:", tripId);
};
// Reload danh sách để cập nhật trạng thái
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 100);
} catch (error) {
console.error("❌ Error sending trip for approval:", error);
}
},
[payloadTrips, getTripsList]
);
const handleDeleteTrip = useCallback(
(tripId: string) => {
Alert.alert(
t("diary.cancelTripConfirmTitle") || "Xác nhận hủy chuyến đi",
t("diary.cancelTripConfirmMessage") ||
"Bạn có chắc chắn muốn hủy chuyến đi này?",
[
{
text: t("common.cancel") || "Hủy",
style: "cancel",
},
{
text: t("common.confirm") || "Xác nhận",
style: "destructive",
onPress: async () => {
try {
// Import dynamically để tránh circular dependency
const { tripCancelRequest } = await import(
"@/controller/TripController"
);
// Gọi API hủy chuyến đi
await tripCancelRequest(tripId);
console.log("✅ Trip cancelled:", tripId);
// Reload danh sách để cập nhật trạng thái
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
}, 100);
} catch (error) {
console.error("❌ Error cancelling trip:", error);
Alert.alert(
t("common.error") || "Lỗi",
t("diary.cancelTripError") ||
"Không thể hủy chuyến đi. Vui lòng thử lại."
);
}
},
},
]
);
},
[payloadTrips, getTripsList, t]
);
// Handle sau khi thêm chuyến đi thành công // Handle sau khi thêm chuyến đi thành công
const handleTripAddSuccess = useCallback(() => { const handleTripAddSuccess = useCallback(() => {
@@ -249,6 +330,24 @@ export default function diary() {
}, 100); }, 100);
}, [payloadTrips, getTripsList]); }, [payloadTrips, getTripsList]);
// Handle reload - gọi lại API
const handleReload = useCallback(() => {
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll FlatList lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 100);
}, [payloadTrips, getTripsList]);
// Dynamic styles based on theme // Dynamic styles based on theme
const themedStyles = { const themedStyles = {
safeArea: { safeArea: {
@@ -273,7 +372,6 @@ export default function diary() {
({ item }: { item: any }) => ( ({ item }: { item: any }) => (
<TripCard <TripCard
trip={item} trip={item}
// onPress={() => handleTripPress(item.id)}
onView={() => handleViewTrip(item.id)} onView={() => handleViewTrip(item.id)}
onEdit={() => handleEditTrip(item.id)} onEdit={() => handleEditTrip(item.id)}
onTeam={() => handleViewTeam(item.id)} onTeam={() => handleViewTeam(item.id)}
@@ -334,9 +432,26 @@ export default function diary() {
> >
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<Text style={[styles.titleText, themedStyles.titleText]}> <View style={styles.headerRow}>
{t("diary.title")} <Text style={[styles.titleText, themedStyles.titleText]}>
</Text> {t("diary.title")}
</Text>
<TouchableOpacity
style={[
styles.reloadButton,
{ backgroundColor: colors.backgroundSecondary },
]}
onPress={handleReload}
activeOpacity={0.7}
disabled={loading}
>
{loading && allTrips.length === 0 ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Ionicons name="reload" size={20} color={colors.primary} />
)}
</TouchableOpacity>
</View>
{/* Filter & Add Button Row */} {/* Filter & Add Button Row */}
<View style={styles.actionRow}> <View style={styles.actionRow}>
@@ -417,13 +532,25 @@ const styles = StyleSheet.create({
fontSize: 28, fontSize: 28,
fontWeight: "700", fontWeight: "700",
lineHeight: 36, lineHeight: 36,
marginBottom: 10,
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
android: "Roboto", android: "Roboto",
default: "System", default: "System",
}), }),
}, },
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
},
reloadButton: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
},
actionRow: { actionRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",

View File

@@ -211,7 +211,7 @@ export default function HomeScreen() {
// console.log("No ZoneApproachingAlarm"); // console.log("No ZoneApproachingAlarm");
} }
if (entered.length > 0) { if (entered.length > 0) {
console.log("ZoneEnteredAlarm: ", entered); // console.log("ZoneEnteredAlarm: ", entered);
} else { } else {
// console.log("No ZoneEnteredAlarm"); // console.log("No ZoneEnteredAlarm");
} }

View File

@@ -7,6 +7,7 @@ import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
ScrollView,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
@@ -21,9 +22,10 @@ export default function TripCrewPage() {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const router = useRouter(); const router = useRouter();
const { tripId, tripName } = useLocalSearchParams<{ const { tripId, tripName, tripStatus } = useLocalSearchParams<{
tripId: string; tripId: string;
tripName?: string; tripName?: string;
tripStatus?: string;
}>(); }>();
// State // State
@@ -104,9 +106,8 @@ export default function TripCrewPage() {
); );
}; };
// Save crew handler // Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách
const handleSaveCrew = async (formData: any) => { const handleSaveCrew = async () => {
// TODO: Call API to add/edit crew when available
await fetchCrewData(); await fetchCrewData();
}; };
@@ -146,13 +147,19 @@ export default function TripCrewPage() {
</Text> </Text>
)} )}
</View> </View>
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}> {tripStatus === "0" && (
<Ionicons name="add" size={24} color={colors.primary} /> <TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
</TouchableOpacity> <Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity>
)}
</View> </View>
{/* Content */} {/* Content */}
<View style={styles.content}> <ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
@@ -186,7 +193,7 @@ export default function TripCrewPage() {
onDelete={handleDeleteCrew} onDelete={handleDeleteCrew}
/> />
)} )}
</View> </ScrollView>
{/* Footer - Crew count */} {/* Footer - Crew count */}
<View <View
@@ -224,10 +231,11 @@ export default function TripCrewPage() {
address: editingCrew.Person?.address || "", address: editingCrew.Person?.address || "",
role: editingCrew.role || "crew", role: editingCrew.role || "crew",
// Note lấy từ trip (ghi chú chuyến đi), fallback về note từ Person // Note lấy từ trip (ghi chú chuyến đi), fallback về note từ Person
note: editingCrew.note || editingCrew.Person?.note || "", note: editingCrew.note || "",
} }
: undefined : undefined
} }
tripStatus={tripStatus ? Number(tripStatus) : undefined}
/> />
</SafeAreaView> </SafeAreaView>
); );
@@ -275,7 +283,10 @@ const styles = StyleSheet.create({
}, },
content: { content: {
flex: 1, flex: 1,
},
scrollContent: {
padding: 16, padding: 16,
paddingBottom: 20,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,

View File

@@ -18,6 +18,9 @@ import {
convertFishingGears, convertFishingGears,
convertTripCosts, convertTripCosts,
} from "@/utils/tripDataConverters"; } from "@/utils/tripDataConverters";
import { queryAlarms } from "@/controller/AlarmController";
import { queryTripCrew } from "@/controller/TripCrewController";
import { queryTripById } from "@/controller/TripController";
// Reuse existing components // Reuse existing components
import CrewList from "@/components/diary/TripCrewModal/CrewList"; import CrewList from "@/components/diary/TripCrewModal/CrewList";
@@ -36,28 +39,77 @@ export default function TripDetailPage() {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const router = useRouter(); const router = useRouter();
const { tripId, tripData: tripDataParam } = useLocalSearchParams<{ const { tripId } = useLocalSearchParams<{
tripId: string; tripId: string;
tripData?: string;
}>(); }>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [trip, setTrip] = useState<Model.Trip | null>(null); const [trip, setTrip] = useState<Model.Trip | null>(null);
const [alerts] = useState<Model.Alarm[]>([]); // TODO: Fetch from API const [alerts, setAlerts] = useState<Model.Alarm[]>([]);
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
// Parse trip data from params or fetch from API // Fetch trip data từ API
useEffect(() => { useEffect(() => {
if (tripDataParam) { const fetchTripData = async () => {
if (!tripId) return;
setLoading(true);
try { try {
const parsedTrip = JSON.parse(tripDataParam) as Model.Trip; const response = await queryTripById(tripId);
setTrip(parsedTrip); if (response.data) {
} catch (e) { setTrip(response.data);
console.error("Error parsing trip data:", e); }
} catch (error) {
console.error("Lỗi khi tải thông tin chuyến đi:", error);
} finally {
setLoading(false);
} }
} };
// TODO: Fetch trip detail from API using tripId if not passed via params
setLoading(false); fetchTripData();
}, [tripDataParam, tripId]); }, [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 // Convert trip data to component format using memoization
const fishingGears = useMemo( const fishingGears = useMemo(
@@ -72,11 +124,20 @@ export default function TripDetailPage() {
const statusConfig = useMemo(() => { const statusConfig = useMemo(() => {
const status = trip?.trip_status ?? 0; const status = trip?.trip_status ?? 0;
return TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] || TRIP_STATUS_CONFIG[0]; return (
TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] ||
TRIP_STATUS_CONFIG[0]
);
}, [trip?.trip_status]); }, [trip?.trip_status]);
// Empty section component // Empty section component
const EmptySection = ({ icon, message }: { icon: string; message: string }) => ( const EmptySection = ({
icon,
message,
}: {
icon: string;
message: string;
}) => (
<View style={styles.emptySection}> <View style={styles.emptySection}>
<Ionicons name={icon as any} size={40} color={colors.textSecondary} /> <Ionicons name={icon as any} size={40} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}> <Text style={[styles.emptyText, { color: colors.textSecondary }]}>
@@ -88,7 +149,10 @@ export default function TripDetailPage() {
// Render loading state // Render loading state
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}> <SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}> <Text style={[styles.loadingText, { color: colors.textSecondary }]}>
@@ -102,10 +166,21 @@ export default function TripDetailPage() {
// Render error state // Render error state
if (!trip) { if (!trip) {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}> <SafeAreaView
<Header title={t("diary.tripDetail.title")} onBack={() => router.back()} colors={colors} /> style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
<Header
title={t("diary.tripDetail.title")}
onBack={() => router.back()}
colors={colors}
/>
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} /> <Ionicons
name="alert-circle-outline"
size={48}
color={colors.error || "#FF3B30"}
/>
<Text style={[styles.errorText, { color: colors.textSecondary }]}> <Text style={[styles.errorText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.notFound")} {t("diary.tripDetail.notFound")}
</Text> </Text>
@@ -115,19 +190,44 @@ export default function TripDetailPage() {
} }
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}> <SafeAreaView
style={[styles.container, { backgroundColor: colors.background }]}
edges={["top"]}
>
{/* Header with status badge */} {/* Header with status badge */}
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}> <View
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}> 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} /> <Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerTitles}> <View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}> <Text
style={[styles.title, { color: colors.text }]}
numberOfLines={1}
>
{trip.name || t("diary.tripDetail.title")} {trip.name || t("diary.tripDetail.title")}
</Text> </Text>
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}> <View
<Ionicons name={statusConfig.icon as any} size={12} color={statusConfig.textColor} /> style={[
<Text style={[styles.statusText, { color: statusConfig.textColor }]}> 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} {statusConfig.label}
</Text> </Text>
</View> </View>
@@ -157,10 +257,18 @@ export default function TripDetailPage() {
> >
{tripCosts.length > 0 ? ( {tripCosts.length > 0 ? (
<View style={styles.sectionInnerContent}> <View style={styles.sectionInnerContent}>
<MaterialCostList items={tripCosts} onChange={() => {}} disabled hideTitle /> <MaterialCostList
items={tripCosts}
onChange={() => {}}
disabled
hideTitle
/>
</View> </View>
) : ( ) : (
<EmptySection icon="receipt-outline" message={t("diary.tripDetail.noCosts")} /> <EmptySection
icon="receipt-outline"
message={t("diary.tripDetail.noCosts")}
/>
)} )}
</SectionCard> </SectionCard>
@@ -174,10 +282,18 @@ export default function TripDetailPage() {
> >
{fishingGears.length > 0 ? ( {fishingGears.length > 0 ? (
<View style={styles.sectionInnerContent}> <View style={styles.sectionInnerContent}>
<FishingGearList items={fishingGears} onChange={() => {}} disabled hideTitle /> <FishingGearList
items={fishingGears}
onChange={() => {}}
disabled
hideTitle
/>
</View> </View>
) : ( ) : (
<EmptySection icon="build-outline" message={t("diary.tripDetail.noGears")} /> <EmptySection
icon="build-outline"
message={t("diary.tripDetail.noGears")}
/>
)} )}
</SectionCard> </SectionCard>
@@ -185,14 +301,17 @@ export default function TripDetailPage() {
<SectionCard <SectionCard
title={t("diary.tripDetail.crew")} title={t("diary.tripDetail.crew")}
icon="people-outline" icon="people-outline"
count={trip.crews?.length || 0} count={crews.length}
collapsible collapsible
defaultExpanded defaultExpanded
> >
{trip.crews && trip.crews.length > 0 ? ( {crews.length > 0 ? (
<CrewList crews={trip.crews} /> <CrewList crews={crews} />
) : ( ) : (
<EmptySection icon="person-add-outline" message={t("diary.tripDetail.noCrew")} /> <EmptySection
icon="person-add-outline"
message={t("diary.tripDetail.noCrew")}
/>
)} )}
</SectionCard> </SectionCard>
@@ -214,7 +333,12 @@ function Header({
colors: any; colors: any;
}) { }) {
return ( return (
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}> <View
style={[
styles.header,
{ backgroundColor: colors.card, borderBottomColor: colors.separator },
]}
>
<TouchableOpacity onPress={onBack} style={styles.backButton}> <TouchableOpacity onPress={onBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} /> <Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
@@ -238,7 +362,11 @@ const styles = StyleSheet.create({
}, },
loadingText: { loadingText: {
fontSize: 14, fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
header: { header: {
flexDirection: "row", flexDirection: "row",
@@ -259,7 +387,11 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 18, fontSize: 18,
fontWeight: "700", fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
statusBadge: { statusBadge: {
flexDirection: "row", flexDirection: "row",
@@ -272,7 +404,11 @@ const styles = StyleSheet.create({
statusText: { statusText: {
fontSize: 11, fontSize: 11,
fontWeight: "600", fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
placeholder: { placeholder: {
width: 32, width: 32,
@@ -294,7 +430,11 @@ const styles = StyleSheet.create({
errorText: { errorText: {
fontSize: 14, fontSize: 14,
textAlign: "center", textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
sectionInnerContent: { sectionInnerContent: {
marginTop: 5, marginTop: 5,
@@ -307,6 +447,10 @@ const styles = StyleSheet.create({
}, },
emptyText: { emptyText: {
fontSize: 14, fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
}); });

View File

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

View File

@@ -17,15 +17,14 @@ import {
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import {
searchCrew, import { newTripCrew, updateTripCrew } from "@/controller/TripCrewController";
newTripCrew,
updateTripCrew,
} from "@/controller/TripCrewController";
import { import {
newCrew, newCrew,
searchCrew,
updateCrewInfo, updateCrewInfo,
queryCrewImage, queryCrewImage,
uploadCrewImage,
} from "@/controller/CrewController"; } from "@/controller/CrewController";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
import { Buffer } from "buffer"; import { Buffer } from "buffer";
@@ -48,10 +47,11 @@ interface AddEditCrewModalProps {
initialData?: Partial<CrewFormData>; initialData?: Partial<CrewFormData>;
tripId?: string; // Required for add mode to add crew to trip tripId?: string; // Required for add mode to add crew to trip
existingCrewIds?: string[]; // List of existing crew IDs in 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 ROLES = ["captain", "crew"];
const DEBOUNCE_DELAY = 1000; // 3 seconds debounce const DEBOUNCE_DELAY = 1000; // 1 seconds debounce
export default function AddEditCrewModal({ export default function AddEditCrewModal({
visible, visible,
@@ -61,6 +61,7 @@ export default function AddEditCrewModal({
initialData, initialData,
tripId, tripId,
existingCrewIds = [], existingCrewIds = [],
tripStatus,
}: AddEditCrewModalProps) { }: AddEditCrewModalProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -191,8 +192,24 @@ export default function AddEditCrewModal({
phone: person.phone || prev.phone, phone: person.phone || prev.phone,
email: person.email || prev.email, email: person.email || prev.email,
address: person.address || prev.address, address: person.address || prev.address,
note: person.note || prev.note,
})); }));
// Load avatar của thuyền viên đã tìm thấy
try {
setIsLoadingImage(true);
const imageResponse = await queryCrewImage(personalId.trim());
if (imageResponse.data) {
const base64 = Buffer.from(
imageResponse.data as ArrayBuffer
).toString("base64");
setImageUri(`data:image/jpeg;base64,${base64}`);
}
} catch (imageError) {
// Không có ảnh - hiển thị placeholder
setImageUri(null);
} finally {
setIsLoadingImage(false);
}
} else { } else {
setSearchStatus("not_found"); setSearchStatus("not_found");
setFoundPersonData(null); setFoundPersonData(null);
@@ -337,16 +354,18 @@ export default function AddEditCrewModal({
try { try {
if (mode === "add" && tripId) { if (mode === "add" && tripId) {
// === CHẾ ĐỘ THÊM MỚI === // === 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 // Bước 1: Nếu không tìm thấy thuyền viên trong hệ thống -> tạo mới
if (searchStatus === "not_found" || !foundPersonData) { // Note: KHÔNG gửi note vào newCrew vì note là riêng cho từng chuyến đi
if (isNewCrew) {
await newCrew({ await newCrew({
personal_id: formData.personalId.trim(), personal_id: formData.personalId.trim(),
name: formData.name.trim(), name: formData.name.trim(),
phone: formData.phone || "", phone: formData.phone || "",
email: formData.email || "", email: formData.email || "",
birth_date: new Date(), birth_date: new Date(),
note: formData.note || "", note: "", // Không gửi note - note là riêng cho từng chuyến đi
address: formData.address || "", address: formData.address || "",
}); });
} }
@@ -361,21 +380,54 @@ export default function AddEditCrewModal({
// === CHẾ ĐỘ CHỈNH SỬA === // === 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) // Bước 1: Cập nhật thông tin cá nhân của thuyền viên (không bao gồm note)
await updateCrewInfo(formData.personalId.trim(), { try {
name: formData.name.trim(), await updateCrewInfo(formData.personalId.trim(), {
phone: formData.phone || "", name: formData.name.trim(),
email: formData.email || "", phone: formData.phone || "",
birth_date: new Date(), email: formData.email || "",
address: formData.address || "", birth_date: new Date(),
}); address: formData.address || "",
});
console.log("✅ updateCrewInfo thành công");
} catch (crewError: any) {
console.error("❌ Lỗi updateCrewInfo:", crewError.response?.data);
}
// Bước 2: Cập nhật role và note của thuyền viên trong chuyến đi // Bước 2: Cập nhật role và note (chỉ khi chuyến đi chưa hoàn thành)
await updateTripCrew({ // TripStatus: 0=created, 1=pending, 2=approved, 3=active, 4=completed, 5=cancelled
trip_id: tripId, if (tripStatus !== 4) {
personal_id: formData.personalId.trim(), try {
role: formData.role as "captain" | "crew", await updateTripCrew({
note: formData.note || "", 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 // Gọi callback để reload danh sách
@@ -383,6 +435,8 @@ export default function AddEditCrewModal({
handleClose(); handleClose();
} catch (error: any) { } catch (error: any) {
console.error("Lỗi khi lưu thuyền viên:", error); 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")); Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -507,6 +561,9 @@ export default function AddEditCrewModal({
<ScrollView <ScrollView
style={styles.content} style={styles.content}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 100 }}
automaticallyAdjustKeyboardInsets={true}
> >
{/* Ảnh thuyền viên */} {/* Ảnh thuyền viên */}
<View style={styles.photoSection}> <View style={styles.photoSection}>
@@ -661,21 +718,23 @@ export default function AddEditCrewModal({
/> />
</View> </View>
{/* Note */} {/* Note - Chỉ hiển thị khi edit, ẩn khi add */}
<View style={styles.formGroup}> {mode === "edit" && (
<Text style={[styles.label, themedStyles.label]}> <View style={styles.formGroup}>
{t("diary.crew.note")} <Text style={[styles.label, themedStyles.label]}>
</Text> {t("diary.crew.note")}
<TextInput </Text>
style={[styles.input, styles.textArea, themedStyles.input]} <TextInput
value={formData.note} style={[styles.input, styles.textArea, themedStyles.input]}
onChangeText={(v) => updateField("note", v)} value={formData.note}
placeholder={t("diary.crew.form.notePlaceholder")} onChangeText={(v) => updateField("note", v)}
placeholderTextColor={themedStyles.placeholder.color} placeholder={t("diary.crew.form.notePlaceholder")}
multiline placeholderTextColor={themedStyles.placeholder.color}
numberOfLines={3} multiline
/> numberOfLines={3}
</View> />
</View>
)}
</ScrollView> </ScrollView>
{/* Footer */} {/* Footer */}
@@ -849,7 +908,8 @@ const styles = StyleSheet.create({
footer: { footer: {
flexDirection: "row", flexDirection: "row",
gap: 12, gap: 12,
padding: 20, paddingHorizontal: 20,
paddingVertical: 20,
borderTopWidth: 1, borderTopWidth: 1,
}, },
cancelButton: { cancelButton: {

View File

@@ -1,11 +1,5 @@
import React from "react"; import React from "react";
import { import { View, Text, StyleSheet, FlatList, Platform } from "react-native";
View,
Text,
StyleSheet,
FlatList,
Platform,
} from "react-native";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import CrewCard from "./CrewCard"; import CrewCard from "./CrewCard";
@@ -24,7 +18,7 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
<CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} /> <CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} />
); );
const keyExtractor = (item: Model.TripCrews, index: number) => const keyExtractor = (item: Model.TripCrews, index: number) =>
`${item.PersonalID}-${index}`; `${item.PersonalID}-${index}`;
const renderEmpty = () => ( const renderEmpty = () => (
@@ -43,6 +37,8 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
ListEmptyComponent={renderEmpty} ListEmptyComponent={renderEmpty}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
scrollEnabled={false}
nestedScrollEnabled
/> />
); );
} }

View File

@@ -36,7 +36,9 @@ export default function TripCrewModal({
// Animation values // Animation values
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current; const slideAnim = useRef(
new Animated.Value(Dimensions.get("window").height)
).current;
// State // State
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -81,7 +83,7 @@ export default function TripCrewModal({
try { try {
const response = await queryTripCrew(tripId); const response = await queryTripCrew(tripId);
const data = response.data as any; const data = response.data as any;
if (data?.trip_crews && Array.isArray(data.trip_crews)) { if (data?.trip_crews && Array.isArray(data.trip_crews)) {
setCrews(data.trip_crews); setCrews(data.trip_crews);
} else if (Array.isArray(data)) { } else if (Array.isArray(data)) {
@@ -144,7 +146,9 @@ export default function TripCrewModal({
onPress: async () => { onPress: async () => {
// TODO: Call delete API when available // TODO: Call delete API when available
// For now, just remove from local state // For now, just remove from local state
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID)); setCrews((prev) =>
prev.filter((c) => c.PersonalID !== crew.PersonalID)
);
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
}, },
}, },
@@ -152,10 +156,8 @@ export default function TripCrewModal({
); );
}; };
// Save crew handler (add or edit) // Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách
const handleSaveCrew = async (formData: any) => { const handleSaveCrew = async () => {
// TODO: Call API to add/edit crew when available
// For now, refresh the list
await fetchCrewData(); await fetchCrewData();
}; };
@@ -185,7 +187,10 @@ export default function TripCrewModal({
> >
{/* Header */} {/* Header */}
<View style={[styles.header, themedStyles.header]}> <View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}> <TouchableOpacity
onPress={handleClose}
style={styles.closeButton}
>
<Ionicons name="close" size={24} color={colors.text} /> <Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerTitles}> <View style={styles.headerTitles}>
@@ -193,13 +198,19 @@ export default function TripCrewModal({
{t("diary.crew.title")} {t("diary.crew.title")}
</Text> </Text>
{tripName && ( {tripName && (
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}> <Text
style={[styles.subtitle, themedStyles.subtitle]}
numberOfLines={1}
>
{tripName} {tripName}
</Text> </Text>
)} )}
</View> </View>
{/* Add Button */} {/* Add Button */}
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}> <TouchableOpacity
onPress={handleAddCrew}
style={styles.addButton}
>
<Ionicons name="add" size={24} color={colors.primary} /> <Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -209,21 +220,40 @@ export default function TripCrewModal({
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}> <Text
style={[
styles.loadingText,
{ color: colors.textSecondary },
]}
>
{t("diary.crew.loading")} {t("diary.crew.loading")}
</Text> </Text>
</View> </View>
) : error ? ( ) : error ? (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} /> <Ionicons
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}> name="alert-circle-outline"
size={48}
color={colors.error || "#FF3B30"}
/>
<Text
style={[
styles.errorText,
{ color: colors.error || "#FF3B30" },
]}
>
{error} {error}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.retryButton, { backgroundColor: colors.primary }]} style={[
styles.retryButton,
{ backgroundColor: colors.primary },
]}
onPress={fetchCrewData} onPress={fetchCrewData}
> >
<Text style={styles.retryButtonText}>{t("common.retry")}</Text> <Text style={styles.retryButtonText}>
{t("common.retry")}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@@ -238,7 +268,11 @@ export default function TripCrewModal({
{/* Footer - Crew count */} {/* Footer - Crew count */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}> <View style={[styles.footer, { borderTopColor: colors.separator }]}>
<View style={styles.countContainer}> <View style={styles.countContainer}>
<Ionicons name="people-outline" size={20} color={colors.primary} /> <Ionicons
name="people-outline"
size={20}
color={colors.primary}
/>
<Text style={[styles.countText, { color: colors.text }]}> <Text style={[styles.countText, { color: colors.text }]}>
{t("diary.crew.totalMembers", { count: crews.length })} {t("diary.crew.totalMembers", { count: crews.length })}
</Text> </Text>
@@ -257,6 +291,8 @@ export default function TripCrewModal({
}} }}
onSave={handleSaveCrew} onSave={handleSaveCrew}
mode={editingCrew ? "edit" : "add"} mode={editingCrew ? "edit" : "add"}
tripId={tripId || undefined}
existingCrewIds={crews.map((c) => c.PersonalID)}
initialData={ initialData={
editingCrew editingCrew
? { ? {
@@ -313,12 +349,20 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 18, fontSize: 18,
fontWeight: "700", fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
subtitle: { subtitle: {
fontSize: 13, fontSize: 13,
marginTop: 2, marginTop: 2,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
content: { content: {
flex: 1, flex: 1,
@@ -332,7 +376,11 @@ const styles = StyleSheet.create({
}, },
loadingText: { loadingText: {
fontSize: 14, fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
errorContainer: { errorContainer: {
flex: 1, flex: 1,
@@ -344,7 +392,11 @@ const styles = StyleSheet.create({
errorText: { errorText: {
fontSize: 14, fontSize: 14,
textAlign: "center", textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
retryButton: { retryButton: {
marginTop: 8, marginTop: 8,
@@ -356,7 +408,11 @@ const styles = StyleSheet.create({
color: "#FFFFFF", color: "#FFFFFF",
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
footer: { footer: {
borderTopWidth: 1, borderTopWidth: 1,
@@ -372,6 +428,10 @@ const styles = StyleSheet.create({
countText: { countText: {
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
}); });

View File

@@ -14,15 +14,32 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const getAlertLevelColor = (level?: number) => { const getAlertLevelColor = (level?: number) => {
const isDark =
colors.background === "#1C1C1E" ||
colors.background === "#000000" ||
colors.background?.toLowerCase().includes("1c1c1e");
switch (level) { switch (level) {
case 0: case 0: // Bình thường - Blue/Info
return { bg: "#FEF3C7", text: "#92400E" }; // Warning - Yellow return isDark
case 1: ? { bg: "#172554", text: "#93C5FD" } // Dark blue
return { bg: "#FEE2E2", text: "#991B1B" }; // Error - Red : { bg: "#DBEAFE", text: "#1E40AF" };
case 2: case 1: // Warning - Yellow
return { bg: "#DBEAFE", text: "#1E40AF" }; // Info - Blue return isDark
default: ? { bg: "#422006", text: "#FCD34D" } // Dark amber
return { bg: "#F3F4F6", text: "#4B5563" }; // Default - Gray : { bg: "#FEF3C7", text: "#92400E" };
case 2: // Error - Red
return isDark
? { bg: "#450A0A", text: "#FCA5A5" } // Dark red
: { bg: "#FEE2E2", text: "#991B1B" };
case 3: // SOS - Critical Red
return isDark
? { bg: "#7F1D1D", text: "#FFFFFF" } // Dark critical
: { bg: "#DC2626", text: "#FFFFFF" };
default: // Default - Gray
return isDark
? { bg: "#374151", text: "#D1D5DB" } // Dark gray
: { bg: "#F3F4F6", text: "#4B5563" };
} }
}; };
@@ -38,11 +55,16 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
icon="warning-outline" icon="warning-outline"
count={alerts.length} count={alerts.length}
collapsible collapsible
defaultExpanded={alerts.length > 0} //defaultExpanded={alerts.length > 0}
defaultExpanded
> >
{alerts.length === 0 ? ( {alerts.length === 0 ? (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Ionicons name="checkmark-circle-outline" size={40} color={colors.success || "#22C55E"} /> <Ionicons
name="checkmark-circle-outline"
size={40}
color={colors.success || "#22C55E"}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}> <Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noAlerts")} {t("diary.tripDetail.noAlerts")}
</Text> </Text>
@@ -63,13 +85,22 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
size={18} size={18}
color={levelColor.text} color={levelColor.text}
/> />
<Text style={[styles.alertName, { color: levelColor.text }]}> <Text
style={[styles.alertName, { color: levelColor.text }]}
>
{alert.name || t("diary.tripDetail.unknownAlert")} {alert.name || t("diary.tripDetail.unknownAlert")}
</Text> </Text>
</View> </View>
{alert.confirmed && ( {alert.confirmed && (
<View style={[styles.confirmedBadge, { backgroundColor: colors.success || "#22C55E" }]}> <View
<Text style={styles.confirmedText}>{t("diary.tripDetail.confirmed")}</Text> style={[
styles.confirmedBadge,
{ backgroundColor: colors.success || "#22C55E" },
]}
>
<Text style={styles.confirmedText}>
{t("diary.tripDetail.confirmed")}
</Text>
</View> </View>
)} )}
</View> </View>

View File

@@ -1,8 +1,12 @@
import React from "react"; import React, { useEffect, useMemo } from "react";
import { View, Text, StyleSheet, Platform } from "react-native"; import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
import { usePort } from "@/state/use-ports";
import { useGroup } from "@/state/use-group";
import { filterPortsByProvinceCode } from "@/utils/tripDataConverters";
interface BasicInfoSectionProps { interface BasicInfoSectionProps {
trip: Model.Trip; trip: Model.Trip;
@@ -15,6 +19,55 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); 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) => { const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "--"; if (!dateStr) return "--";
const date = new Date(dateStr); const date = new Date(dateStr);
@@ -30,8 +83,13 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const infoItems = [ const infoItems = [
{ {
icon: "boat" as const, icon: "boat" as const,
label: t("diary.tripDetail.shipId"), label: t("diary.tripDetail.shipName"),
value: trip.vms_id || "--", value: shipName,
},
{
icon: "barcode" as const,
label: t("diary.tripDetail.shipCode"),
value: shipCode,
}, },
{ {
icon: "play-circle" as const, icon: "play-circle" as const,
@@ -46,26 +104,36 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{ {
icon: "location" as const, icon: "location" as const,
label: t("diary.tripDetail.departurePort"), label: t("diary.tripDetail.departurePort"),
value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--", value: getPortName(trip.departure_port_id),
}, },
{ {
icon: "flag" as const, icon: "flag" as const,
label: t("diary.tripDetail.arrivalPort"), label: t("diary.tripDetail.arrivalPort"),
value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--", value: getPortName(trip.arrival_port_id),
}, },
{ {
icon: "map" as const, icon: "map" as const,
label: t("diary.tripDetail.fishingGrounds"), label: t("diary.tripDetail.fishingGrounds"),
value: trip.fishing_ground_codes?.length > 0 value:
? trip.fishing_ground_codes.join(", ") trip.fishing_ground_codes?.length > 0
: "--", ? trip.fishing_ground_codes.join(", ")
: "--",
}, },
]; ];
return ( return (
<View style={[styles.container, { backgroundColor: colors.card, borderColor: colors.separator }]}> <View
style={[
styles.container,
{ backgroundColor: colors.card, borderColor: colors.separator },
]}
>
<View style={styles.header}> <View style={styles.header}>
<Ionicons name="information-circle-outline" size={20} color={colors.primary} /> <Ionicons
name="information-circle-outline"
size={20}
color={colors.primary}
/>
<Text style={[styles.title, { color: colors.text }]}> <Text style={[styles.title, { color: colors.text }]}>
{t("diary.tripDetail.basicInfo")} {t("diary.tripDetail.basicInfo")}
</Text> </Text>
@@ -74,8 +142,14 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{infoItems.map((item, index) => ( {infoItems.map((item, index) => (
<View key={index} style={styles.infoItem}> <View key={index} style={styles.infoItem}>
<View style={styles.infoLabel}> <View style={styles.infoLabel}>
<Ionicons name={item.icon} size={16} color={colors.textSecondary} /> <Ionicons
<Text style={[styles.infoLabelText, { color: colors.textSecondary }]}> name={item.icon}
size={16}
color={colors.textSecondary}
/>
<Text
style={[styles.infoLabelText, { color: colors.textSecondary }]}
>
{item.label} {item.label}
</Text> </Text>
</View> </View>
@@ -106,7 +180,11 @@ const styles = StyleSheet.create({
title: { title: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
content: { content: {
paddingHorizontal: 16, paddingHorizontal: 16,
@@ -125,11 +203,19 @@ const styles = StyleSheet.create({
}, },
infoLabelText: { infoLabelText: {
fontSize: 13, fontSize: 13,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
infoValue: { infoValue: {
fontSize: 13, fontSize: 13,
fontWeight: "500", fontWeight: "500",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
}); });

View File

@@ -9,7 +9,9 @@ interface FishingLogsSectionProps {
fishingLogs?: Model.FishingLog[] | null; fishingLogs?: Model.FishingLog[] | null;
} }
export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSectionProps) { export default function FishingLogsSection({
fishingLogs = [],
}: FishingLogsSectionProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -35,13 +37,29 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
const getStatusLabel = (status?: number) => { const getStatusLabel = (status?: number) => {
switch (status) { switch (status) {
case 0: case 0:
return { label: t("diary.tripDetail.logStatusPending"), color: "#FEF3C7", textColor: "#92400E" }; return {
label: t("diary.tripDetail.logStatusProcessing"),
color: "#FEF3C7",
textColor: "#92400E",
};
case 1: case 1:
return { label: t("diary.tripDetail.logStatusActive"), color: "#DBEAFE", textColor: "#1E40AF" }; return {
label: t("diary.tripDetail.logStatusSuccess"),
color: "#D1FAE5",
textColor: "#065F46",
};
case 2: case 2:
return { label: t("diary.tripDetail.logStatusCompleted"), color: "#D1FAE5", textColor: "#065F46" }; return {
label: t("diary.tripDetail.logStatusCancelled"),
color: "#FEE2E2",
textColor: "#B91C1C",
};
default: default:
return { label: t("diary.tripDetail.logStatusUnknown"), color: "#F3F4F6", textColor: "#4B5563" }; return {
label: t("diary.tripDetail.logStatusUnknown"),
color: "#F3F4F6",
textColor: "#4B5563",
};
} }
}; };
@@ -55,7 +73,11 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
> >
{logs.length === 0 ? ( {logs.length === 0 ? (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Ionicons name="fish-outline" size={40} color={colors.textSecondary} /> <Ionicons
name="fish-outline"
size={40}
color={colors.textSecondary}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}> <Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noFishingLogs")} {t("diary.tripDetail.noFishingLogs")}
</Text> </Text>
@@ -65,7 +87,7 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{logs.map((log, index) => { {logs.map((log, index) => {
const status = getStatusLabel(log.status); const status = getStatusLabel(log.status);
const catchCount = log.info?.length || 0; const catchCount = log.info?.length || 0;
return ( return (
<View <View
key={log.fishing_log_id || index} key={log.fishing_log_id || index}
@@ -74,12 +96,21 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Header */} {/* Header */}
<View style={styles.logHeader}> <View style={styles.logHeader}>
<View style={styles.logIndex}> <View style={styles.logIndex}>
<Text style={[styles.logIndexText, { color: colors.primary }]}> <Text
style={[styles.logIndexText, { color: colors.primary }]}
>
#{index + 1} #{index + 1}
</Text> </Text>
</View> </View>
<View style={[styles.statusBadge, { backgroundColor: status.color }]}> <View
<Text style={[styles.statusText, { color: status.textColor }]}> style={[
styles.statusBadge,
{ backgroundColor: status.color },
]}
>
<Text
style={[styles.statusText, { color: status.textColor }]}
>
{status.label} {status.label}
</Text> </Text>
</View> </View>
@@ -88,8 +119,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Time Info */} {/* Time Info */}
<View style={styles.timeRow}> <View style={styles.timeRow}>
<View style={styles.timeItem}> <View style={styles.timeItem}>
<Ionicons name="play-circle-outline" size={16} color={colors.success || "#22C55E"} /> <Ionicons
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}> name="play-circle-outline"
size={16}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startTime")}: {t("diary.tripDetail.startTime")}:
</Text> </Text>
<Text style={[styles.timeValue, { color: colors.text }]}> <Text style={[styles.timeValue, { color: colors.text }]}>
@@ -97,8 +137,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
</Text> </Text>
</View> </View>
<View style={styles.timeItem}> <View style={styles.timeItem}>
<Ionicons name="stop-circle-outline" size={16} color={colors.error || "#EF4444"} /> <Ionicons
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}> name="stop-circle-outline"
size={16}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.endTime")}: {t("diary.tripDetail.endTime")}:
</Text> </Text>
<Text style={[styles.timeValue, { color: colors.text }]}> <Text style={[styles.timeValue, { color: colors.text }]}>
@@ -108,22 +157,49 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
</View> </View>
{/* Location Info */} {/* Location Info */}
<View style={[styles.locationContainer, { backgroundColor: colors.backgroundSecondary }]}> <View
style={[
styles.locationContainer,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<View style={styles.locationItem}> <View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.success || "#22C55E"} /> <Ionicons
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}> name="location"
size={14}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startLocation")}: {t("diary.tripDetail.startLocation")}:
</Text> </Text>
<Text style={[styles.locationValue, { color: colors.text }]}> <Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.start_lat, log.start_lon)} {formatCoord(log.start_lat, log.start_lon)}
</Text> </Text>
</View> </View>
<View style={styles.locationItem}> <View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.error || "#EF4444"} /> <Ionicons
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}> name="location"
size={14}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.haulLocation")}: {t("diary.tripDetail.haulLocation")}:
</Text> </Text>
<Text style={[styles.locationValue, { color: colors.text }]}> <Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.haul_lat, log.haul_lon)} {formatCoord(log.haul_lat, log.haul_lon)}
</Text> </Text>
</View> </View>
@@ -135,22 +211,33 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
<View style={styles.catchHeader}> <View style={styles.catchHeader}>
<Ionicons name="fish" size={16} color={colors.primary} /> <Ionicons name="fish" size={16} color={colors.primary} />
<Text style={[styles.catchLabel, { color: colors.text }]}> <Text style={[styles.catchLabel, { color: colors.text }]}>
{t("diary.tripDetail.catchInfo")} ({catchCount} {t("diary.tripDetail.species")}) {t("diary.tripDetail.catchInfo")} ({catchCount}{" "}
{t("diary.tripDetail.species")})
</Text> </Text>
</View> </View>
<View style={styles.catchList}> <View style={styles.catchList}>
{log.info?.slice(0, 3).map((fish, fishIndex) => ( {log.info?.slice(0, 3).map((fish, fishIndex) => (
<View key={fishIndex} style={styles.catchItem}> <View key={fishIndex} style={styles.catchItem}>
<Text style={[styles.fishName, { color: colors.text }]}> <Text
{fish.fish_name || t("diary.tripDetail.unknownFish")} style={[styles.fishName, { color: colors.text }]}
>
{fish.fish_name ||
t("diary.tripDetail.unknownFish")}
</Text> </Text>
<Text style={[styles.fishAmount, { color: colors.textSecondary }]}> <Text
style={[
styles.fishAmount,
{ color: colors.textSecondary },
]}
>
{fish.catch_number} {fish.catch_unit} {fish.catch_number} {fish.catch_unit}
</Text> </Text>
</View> </View>
))} ))}
{catchCount > 3 && ( {catchCount > 3 && (
<Text style={[styles.moreText, { color: colors.primary }]}> <Text
style={[styles.moreText, { color: colors.primary }]}
>
+{catchCount - 3} {t("diary.tripDetail.more")} +{catchCount - 3} {t("diary.tripDetail.more")}
</Text> </Text>
)} )}
@@ -161,8 +248,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Weather */} {/* Weather */}
{log.weather_description && ( {log.weather_description && (
<View style={styles.weatherRow}> <View style={styles.weatherRow}>
<Ionicons name="cloudy-outline" size={14} color={colors.textSecondary} /> <Ionicons
<Text style={[styles.weatherText, { color: colors.textSecondary }]}> name="cloudy-outline"
size={14}
color={colors.textSecondary}
/>
<Text
style={[
styles.weatherText,
{ color: colors.textSecondary },
]}
>
{log.weather_description} {log.weather_description}
</Text> </Text>
</View> </View>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
View, View,
Text, Text,
@@ -20,6 +20,8 @@ interface TripDurationPickerProps {
disabled?: boolean; disabled?: boolean;
} }
type PickerType = "startDate" | "startTime" | "endDate" | "endTime" | null;
export default function TripDurationPicker({ export default function TripDurationPicker({
startDate, startDate,
endDate, endDate,
@@ -27,15 +29,27 @@ export default function TripDurationPicker({
onEndDateChange, onEndDateChange,
disabled = false, disabled = false,
}: TripDurationPickerProps) { }: TripDurationPickerProps) {
const { t } = useI18n(); const { t, locale } = useI18n();
const { colors, colorScheme } = useThemeContext(); const { colors, colorScheme } = useThemeContext();
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false); // Single state for which picker is showing
const [activePicker, setActivePicker] = useState<PickerType>(null);
// Temp states to hold the picker value before confirming // Temp states to hold the picker value before confirming
const [tempStartDate, setTempStartDate] = useState<Date>(new Date()); const [tempStartDate, setTempStartDate] = useState<Date>(new Date());
const [tempEndDate, setTempEndDate] = 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) => { const formatDate = (date: Date | null) => {
if (!date) return ""; if (!date) return "";
const day = date.getDate().toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0");
@@ -44,62 +58,102 @@ export default function TripDurationPicker({
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`;
}; };
const handleOpenStartPicker = () => { const formatTime = (date: Date | null) => {
if (!date) return "";
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
// Open start date picker
const handleOpenStartDatePicker = () => {
const today = new Date(); const today = new Date();
const dateToUse = startDate || today; const dateToUse = startDate || today;
// If no date selected, immediately set to today
if (!startDate) { if (!startDate) {
onStartDateChange(today); onStartDateChange(today);
} }
// Always set tempStartDate to the date we're using (today if no date was selected)
setTempStartDate(dateToUse); setTempStartDate(dateToUse);
setShowStartPicker(true); setActivePicker("startDate");
}; };
const handleOpenEndPicker = () => { // Open start time picker
const handleOpenStartTimePicker = () => {
const today = new Date();
const dateToUse = startDate || today;
if (!startDate) {
onStartDateChange(today);
}
setTempStartDate(dateToUse);
setActivePicker("startTime");
};
// Open end date picker
const handleOpenEndDatePicker = () => {
const today = new Date(); const today = new Date();
const dateToUse = endDate || today; const dateToUse = endDate || today;
// If no date selected, immediately set to today
if (!endDate) { if (!endDate) {
onEndDateChange(today); onEndDateChange(today);
} }
// Always set tempEndDate to the date we're using (today if no date was selected)
setTempEndDate(dateToUse); setTempEndDate(dateToUse);
setShowEndPicker(true); setActivePicker("endDate");
}; };
const handleStartDateChange = (event: any, selectedDate?: Date) => { // Open end time picker
const handleOpenEndTimePicker = () => {
const today = new Date();
const dateToUse = endDate || today;
if (!endDate) {
onEndDateChange(today);
}
setTempEndDate(dateToUse);
setActivePicker("endTime");
};
const handleStartPickerChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
setShowStartPicker(false); setActivePicker(null);
if (event.type === "set" && selectedDate) { if (event.type === "set" && selectedDate) {
onStartDateChange(selectedDate); onStartDateChange(selectedDate);
} }
} else if (selectedDate) { } else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempStartDate(selectedDate); setTempStartDate(selectedDate);
onStartDateChange(selectedDate); onStartDateChange(selectedDate);
} }
}; };
const handleEndDateChange = (event: any, selectedDate?: Date) => { const handleEndPickerChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
setShowEndPicker(false); setActivePicker(null);
if (event.type === "set" && selectedDate) { if (event.type === "set" && selectedDate) {
onEndDateChange(selectedDate); onEndDateChange(selectedDate);
} }
} else if (selectedDate) { } else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempEndDate(selectedDate); setTempEndDate(selectedDate);
onEndDateChange(selectedDate); onEndDateChange(selectedDate);
} }
}; };
const handleConfirmStartDate = () => { const handleConfirm = () => {
setShowStartPicker(false); setActivePicker(null);
}; };
const handleConfirmEndDate = () => { const handleCancel = () => {
setShowEndPicker(false); 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 = { const themedStyles = {
@@ -114,133 +168,230 @@ export default function TripDurationPicker({
pickerHeader: { borderBottomColor: colors.border }, pickerHeader: { borderBottomColor: colors.border },
pickerTitle: { color: colors.text }, pickerTitle: { color: colors.text },
cancelButton: { color: colors.textSecondary }, cancelButton: { color: colors.textSecondary },
sectionCard: {
backgroundColor: colors.backgroundSecondary || colors.card,
borderColor: colors.border,
},
sectionTitle: { color: colors.text },
}; };
return ( const renderDateTimeSection = (
<View style={styles.container}> type: "start" | "end",
<Text style={[styles.label, themedStyles.label]}> date: Date | null,
{t("diary.tripDuration")} onOpenDate: () => void,
</Text> onOpenTime: () => void
<View style={styles.dateRangeContainer}> ) => {
{/* Start Date */} const isStart = type === "start";
<View style={styles.dateSection}> const icon = isStart ? "boat-outline" : "flag-outline";
<Text style={[styles.subLabel, themedStyles.placeholder]}> const title = isStart ? t("diary.startDate") : t("diary.endDate");
{t("diary.startDate")} 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> </Text>
</View>
{/* Date and Time Row */}
<View style={styles.dateTimeRow}>
{/* Date Picker */}
<TouchableOpacity <TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]} style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : handleOpenStartPicker} onPress={disabled ? undefined : onOpenDate}
activeOpacity={disabled ? 1 : 0.7} activeOpacity={disabled ? 1 : 0.7}
disabled={disabled} disabled={disabled}
> >
<Text <View style={styles.inputContent}>
style={[
styles.dateText,
themedStyles.dateText,
!startDate && themedStyles.placeholder,
]}
>
{startDate ? formatDate(startDate) : t("diary.selectDate")}
</Text>
{!disabled && (
<Ionicons <Ionicons
name="calendar-outline" name="calendar-outline"
size={20} 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} color={colors.textSecondary}
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View>
{/* End Date */} {/* Time Picker */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.endDate")}
</Text>
<TouchableOpacity <TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]} style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : handleOpenEndPicker} onPress={disabled ? undefined : onOpenTime}
activeOpacity={disabled ? 1 : 0.7} activeOpacity={disabled ? 1 : 0.7}
disabled={disabled} disabled={disabled}
> >
<Text <View style={styles.inputContent}>
style={[ <Ionicons
styles.dateText, name="time-outline"
themedStyles.dateText, size={18}
!endDate && themedStyles.placeholder, color={date ? colors.primary : colors.textSecondary}
]} style={styles.inputIcon}
> />
{endDate ? formatDate(endDate) : t("diary.selectDate")} <View style={styles.inputTextContainer}>
</Text> <Text style={[styles.inputLabel, themedStyles.placeholder]}>
{timeLabel}
</Text>
<Text
style={[
styles.inputValue,
themedStyles.dateText,
!date && themedStyles.placeholder,
]}
>
{date ? formatTime(date) : "--:--"}
</Text>
</View>
</View>
{!disabled && ( {!disabled && (
<Ionicons <Ionicons
name="calendar-outline" name="chevron-forward"
size={20} size={16}
color={colors.textSecondary} color={colors.textSecondary}
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
);
};
{/* Start Date Picker */} const isStartPicker =
{showStartPicker && ( activePicker === "startDate" || activePicker === "startTime";
<Modal transparent animationType="fade" visible={showStartPicker}> const isTimePicker =
<View style={styles.modalOverlay}> activePicker === "startTime" || activePicker === "endTime";
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}> return (
<TouchableOpacity onPress={() => setShowStartPicker(false)}> <View style={styles.container}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}> <Text style={[styles.label, themedStyles.label]}>
{t("common.cancel")} {t("diary.tripDuration")}
</Text> </Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}> {/* Hiển thị thời gian hiện tại */}
{t("diary.selectStartDate")} <View
</Text> style={[
<TouchableOpacity onPress={handleConfirmStartDate}> styles.currentTimeContainer,
<Text style={styles.doneButton}>{t("common.done")}</Text> { backgroundColor: colors.backgroundSecondary || colors.card },
</TouchableOpacity> ]}
</View> >
<DateTimePicker <Ionicons name="time-outline" size={18} color={colors.primary} />
value={tempStartDate} <Text
mode="date" style={[styles.currentTimeLabel, { color: colors.textSecondary }]}
display={Platform.OS === "ios" ? "spinner" : "default"} >
onChange={handleStartDateChange} {t("diary.currentTime") || "Thời gian hiện tại"}:
maximumDate={endDate || undefined} </Text>
themeVariant={colorScheme} <Text style={[styles.currentTimeValue, { color: colors.primary }]}>
textColor={colors.text} {formatDate(currentTime)} {formatTime(currentTime)}
/> </Text>
</View> </View>
</View>
</Modal> {/* Start Section */}
{renderDateTimeSection(
"start",
startDate,
handleOpenStartDatePicker,
handleOpenStartTimePicker
)} )}
{/* End Date Picker */} {/* Connection Line */}
{showEndPicker && ( <View style={styles.connectionContainer}>
<Modal transparent animationType="fade" visible={showEndPicker}> <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
)}
{/* Unified Picker Modal */}
{activePicker && (
<Modal transparent animationType="fade" visible={!!activePicker}>
<View style={styles.modalOverlay}> <View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}> <View
style={[styles.pickerContainer, themedStyles.pickerContainer]}
>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}> <View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowEndPicker(false)}> <TouchableOpacity onPress={handleCancel}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}> <Text
style={[styles.cancelButtonText, themedStyles.cancelButton]}
>
{t("common.cancel")} {t("common.cancel")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}> <Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectEndDate")} {getPickerTitle()}
</Text> </Text>
<TouchableOpacity onPress={handleConfirmEndDate}> <TouchableOpacity onPress={handleConfirm}>
<Text style={styles.doneButton}>{t("common.done")}</Text> <Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<DateTimePicker <DateTimePicker
value={tempEndDate} value={isStartPicker ? tempStartDate : tempEndDate}
mode="date" mode={isTimePicker ? "time" : "date"}
display={Platform.OS === "ios" ? "spinner" : "default"} display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleEndDateChange} onChange={
minimumDate={startDate || undefined} isStartPicker
? handleStartPickerChange
: handleEndPickerChange
}
maximumDate={
isStartPicker && !isTimePicker
? endDate || undefined
: undefined
}
minimumDate={
!isStartPicker && !isTimePicker
? startDate || undefined
: undefined
}
themeVariant={colorScheme} themeVariant={colorScheme}
textColor={colors.text} textColor={colors.text}
locale={locale}
/> />
</View> </View>
</View> </View>
@@ -257,46 +408,100 @@ const styles = StyleSheet.create({
label: { label: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",
marginBottom: 12, marginBottom: 16,
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
android: "Roboto", android: "Roboto",
default: "System", default: "System",
}), }),
}, },
subLabel: { sectionCard: {
fontSize: 14, borderRadius: 12,
marginBottom: 6, borderWidth: 1,
fontFamily: Platform.select({ padding: 14,
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
dateRangeContainer: { sectionHeader: {
flexDirection: "row", flexDirection: "row",
gap: 12, alignItems: "center",
marginBottom: 12,
}, },
dateSection: { iconContainer: {
width: 32,
height: 32,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
sectionTitle: {
fontSize: 15,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
},
dateTimeInput: {
flex: 1, flex: 1,
},
dateInput: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
borderWidth: 1, borderWidth: 1,
borderRadius: 8, borderRadius: 10,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 12, paddingVertical: 10,
}, },
dateText: { inputContent: {
fontSize: 15, flexDirection: "row",
alignItems: "center",
flex: 1,
},
inputIcon: {
marginRight: 10,
},
inputTextContainer: {
flex: 1,
},
inputLabel: {
fontSize: 11,
marginBottom: 2,
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
android: "Roboto", android: "Roboto",
default: "System", 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: { modalOverlay: {
flex: 1, flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -324,7 +529,7 @@ const styles = StyleSheet.create({
default: "System", default: "System",
}), }),
}, },
cancelButton: { cancelButtonText: {
fontSize: 16, fontSize: 16,
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
@@ -342,4 +547,29 @@ const styles = StyleSheet.create({
default: "System", default: "System",
}), }),
}, },
currentTimeContainer: {
flexDirection: "row",
alignItems: "center",
padding: 12,
borderRadius: 8,
marginBottom: 12,
gap: 8,
},
currentTimeLabel: {
fontSize: 13,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
currentTimeValue: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
}); });

View File

@@ -227,6 +227,30 @@ export default function TripFormModal({
Alert.alert(t("common.error"), t("diary.validation.datesRequired")); Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return false; 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()) { if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired")); Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return false; return false;
@@ -237,7 +261,6 @@ export default function TripFormModal({
// Build API body // Build API body
const buildApiBody = useCallback((): Model.TripAPIBody => { const buildApiBody = useCallback((): Model.TripAPIBody => {
return { return {
thing_id: selectedShipId,
name: tripName, name: tripName,
departure_time: startDate?.toISOString() || "", departure_time: startDate?.toISOString() || "",
departure_port_id: departurePortId, departure_port_id: departurePortId,
@@ -257,7 +280,6 @@ export default function TripFormModal({
})), })),
}; };
}, [ }, [
selectedShipId,
tripName, tripName,
startDate, startDate,
endDate, endDate,
@@ -293,10 +315,28 @@ export default function TripFormModal({
isEditMode ? "Error updating trip:" : "Error creating trip:", isEditMode ? "Error updating trip:" : "Error creating trip:",
error error
); );
Alert.alert(
t("common.error"), // Lấy message từ server response
isEditMode ? t("diary.updateTripError") : t("diary.createTripError") 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -356,7 +396,10 @@ export default function TripFormModal({
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />} {!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* Section 1: Basic Information */} {/* Section 1: Basic Information */}
<FormSection title={t("diary.formSection.basicInfo")} icon="boat-outline"> <FormSection
title={t("diary.formSection.basicInfo")}
icon="boat-outline"
>
{/* Ship Selector - disabled in edit mode */} {/* Ship Selector - disabled in edit mode */}
<ShipSelector <ShipSelector
selectedShipId={selectedShipId} selectedShipId={selectedShipId}
@@ -369,7 +412,10 @@ export default function TripFormModal({
</FormSection> </FormSection>
{/* Section 2: Schedule & Location */} {/* Section 2: Schedule & Location */}
<FormSection title={t("diary.formSection.schedule")} icon="calendar-outline"> <FormSection
title={t("diary.formSection.schedule")}
icon="calendar-outline"
>
{/* Trip Duration */} {/* Trip Duration */}
<TripDurationPicker <TripDurationPicker
startDate={startDate} startDate={startDate}
@@ -394,13 +440,27 @@ export default function TripFormModal({
</FormSection> </FormSection>
{/* Section 3: Equipment */} {/* Section 3: Equipment */}
<FormSection title={t("diary.formSection.equipment")} icon="construct-outline"> <FormSection
<FishingGearList items={fishingGears} onChange={setFishingGears} hideTitle /> title={t("diary.formSection.equipment")}
icon="construct-outline"
>
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
hideTitle
/>
</FormSection> </FormSection>
{/* Section 4: Costs */} {/* Section 4: Costs */}
<FormSection title={t("diary.formSection.costs")} icon="wallet-outline"> <FormSection
<MaterialCostList items={tripCosts} onChange={setTripCosts} hideTitle /> title={t("diary.formSection.costs")}
icon="wallet-outline"
>
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
hideTitle
/>
</FormSection> </FormSection>
</ScrollView> </ScrollView>

View File

@@ -40,13 +40,11 @@ export const STATUS_SOS = 3;
export const API_PATH_LOGIN = "/api/tokens"; export const API_PATH_LOGIN = "/api/tokens";
export const API_PATH_GET_PROFILE = "/api/users/profile"; export const API_PATH_GET_PROFILE = "/api/users/profile";
export const API_PATH_SEARCH_THINGS = "/api/things/search"; export const API_PATH_SEARCH_THINGS = "/api/things/search";
export const API_PATH_ENTITIES = "/api/io/entities";
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo"; export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist"; export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
export const API_GET_LAYER_INFO = "/api/sgw/geojson"; export const API_GET_LAYER_INFO = "/api/sgw/geojson";
export const API_GET_TRIP = "/api/sgw/trip"; export const API_GET_TRIP = "/api/sgw/trip";
export const API_POST_TRIPSLIST = "api/sgw/tripslist"; export const API_POST_TRIPSLIST = "api/sgw/tripslist";
export const API_GET_ALARMS = "/api/io/alarms";
export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState"; export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
export const API_HAUL_HANDLE = "/api/sgw/fishingLog"; export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
export const API_GET_GPS = "/api/sgw/gps"; export const API_GET_GPS = "/api/sgw/gps";
@@ -69,3 +67,6 @@ export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";
export const API_SEARCH_CREW = "/api/sgw/trips/crew/"; export const API_SEARCH_CREW = "/api/sgw/trips/crew/";
export const API_TRIP_CREW = "/api/sgw/tripcrew"; export const API_TRIP_CREW = "/api/sgw/tripcrew";
export const API_CREW = "/api/sgw/crew"; export const API_CREW = "/api/sgw/crew";
export const API_GET_TRIP_BY_ID = "/api/sgw/trips-by-id";
export const API_TRIP_APPROVE_REQUEST = "/api/sgw/trips-approve-request";
export const API_TRIP_CANCEL_REQUEST = "/api/sgw/trips-cancel-request";

View File

@@ -1,10 +1,14 @@
import { api } from "@/config"; import { api } from "@/config";
import { API_CREW, API_GET_PHOTO } from "@/constants"; import { API_CREW, API_GET_PHOTO, API_SEARCH_CREW } from "@/constants";
export async function newCrew(body: Model.NewCrewAPIRequest) { export async function newCrew(body: Model.NewCrewAPIRequest) {
return api.post(API_CREW, body); 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( export async function updateCrewInfo(
personalId: string, personalId: string,
body: Model.UpdateCrewAPIRequest body: Model.UpdateCrewAPIRequest
@@ -17,3 +21,60 @@ export async function queryCrewImage(personal_id: string) {
responseType: "arraybuffer", responseType: "arraybuffer",
}); });
} }
// Upload ảnh thuyền viên
// Hỗ trợ các định dạng: HEIC, jpg, jpeg, png
export async function uploadCrewImage(personalId: string, imageUri: string) {
// Lấy tên file và extension từ URI
const uriParts = imageUri.split("/");
const fileName = uriParts[uriParts.length - 1];
// Xác định MIME type dựa trên extension
const extension = fileName.split(".").pop()?.toLowerCase() || "jpg";
let mimeType = "image/jpeg";
switch (extension) {
case "heic":
mimeType = "image/heic";
break;
case "heif":
mimeType = "image/heif";
break;
case "png":
mimeType = "image/png";
break;
case "gif":
mimeType = "image/gif";
break;
case "webp":
mimeType = "image/webp";
break;
case "jpg":
case "jpeg":
default:
mimeType = "image/jpeg";
break;
}
// Tạo FormData để upload
const formData = new FormData();
formData.append("file", {
uri: imageUri,
name: fileName,
type: mimeType,
} as any);
// Debug logs
console.log("📤 Upload params:");
console.log(" - URI:", imageUri);
console.log(" - fileName:", fileName);
console.log(" - mimeType:", mimeType);
console.log(" - endpoint:", `${API_GET_PHOTO}/people/${personalId}/main`);
// Phải set Content-Type header cho React Native
return api.post(`${API_GET_PHOTO}/people/${personalId}/main`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}

View File

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

View File

@@ -9,12 +9,19 @@ import {
API_POST_TRIP, API_POST_TRIP,
API_PUT_TRIP, API_PUT_TRIP,
API_TRIP_CREW, API_TRIP_CREW,
API_GET_TRIP_BY_ID,
API_TRIP_APPROVE_REQUEST,
API_TRIP_CANCEL_REQUEST,
} from "@/constants"; } from "@/constants";
export async function queryTrip() { export async function queryTrip() {
return api.get<Model.Trip>(API_GET_TRIP); return api.get<Model.Trip>(API_GET_TRIP);
} }
export async function queryTripById(tripId: string) {
return api.get<Model.Trip>(`${API_GET_TRIP_BY_ID}/${tripId}`);
}
export async function queryLastTrip(thingId: string) { export async function queryLastTrip(thingId: string) {
return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`); return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`);
} }
@@ -42,3 +49,11 @@ export async function createTrip(thingId: string, body: Model.TripAPIBody) {
export async function updateTrip(tripId: string, body: Model.TripAPIBody) { export async function updateTrip(tripId: string, body: Model.TripAPIBody) {
return api.put<Model.Trip>(`${API_PUT_TRIP}/${tripId}`, body); return api.put<Model.Trip>(`${API_PUT_TRIP}/${tripId}`, body);
} }
export async function tripApproveRequest(tripId: string) {
return api.put<Model.Trip>(`${API_TRIP_APPROVE_REQUEST}/${tripId}`);
}
export async function tripCancelRequest(tripId: string) {
return api.put<Model.Trip>(`${API_TRIP_CANCEL_REQUEST}/${tripId}`);
}

View File

@@ -7,11 +7,9 @@ import {
} from "@/constants"; } from "@/constants";
export async function queryTripCrew(tripId: string) { export async function queryTripCrew(tripId: string) {
return api.get<Model.TripCrews[]>(`${API_GET_TRIP_CREW}/${tripId}`); return api.get<{ trip_crews: Model.TripCrews[] }>(
} `${API_GET_TRIP_CREW}/${tripId}`
);
export async function searchCrew(personal_id: string) {
return api.get<Model.TripCrewPerson>(`${API_SEARCH_CREW}/${personal_id}`);
} }
export async function newTripCrew(body: Model.NewTripCrewAPIRequest) { export async function newTripCrew(body: Model.NewTripCrewAPIRequest) {

View File

@@ -234,7 +234,6 @@ declare namespace Model {
// API body interface for creating a new trip // API body interface for creating a new trip
interface TripAPIBody { interface TripAPIBody {
thing_id?: string;
name: string; name: string;
departure_time: string; // ISO string departure_time: string; // ISO string
departure_port_id: number; departure_port_id: number;
@@ -372,6 +371,7 @@ declare namespace Model {
dir?: "asc" | "desc"; dir?: "asc" | "desc";
name?: string; name?: string;
level?: number; level?: number;
thing_id?: string;
confirmed?: boolean; confirmed?: boolean;
} }

View File

@@ -3,6 +3,7 @@
"app_name": "Sea Gateway", "app_name": "Sea Gateway",
"footer_text": "Product of Mobifone v1.0", "footer_text": "Product of Mobifone v1.0",
"ok": "OK", "ok": "OK",
"confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"done": "Done", "done": "Done",
"save": "Save", "save": "Save",
@@ -145,11 +146,16 @@
"costPerUnit": "Cost", "costPerUnit": "Cost",
"totalCost": "Total Cost", "totalCost": "Total Cost",
"tripDuration": "Trip Duration", "tripDuration": "Trip Duration",
"startDate": "Start", "currentTime": "Current Time",
"endDate": "End", "startDate": "Departure",
"endDate": "Arrival",
"date": "Date",
"time": "Time",
"selectDate": "Select Date", "selectDate": "Select Date",
"selectStartDate": "Select start date", "selectStartDate": "Select departure date",
"selectEndDate": "Select end date", "selectEndDate": "Select arrival date",
"selectStartTime": "Select departure time",
"selectEndTime": "Select arrival time",
"portLabel": "Port", "portLabel": "Port",
"departurePort": "Departure Port", "departurePort": "Departure Port",
"arrivalPort": "Arrival Port", "arrivalPort": "Arrival Port",
@@ -178,15 +184,21 @@
"validation": { "validation": {
"shipRequired": "Please select a ship before creating the trip", "shipRequired": "Please select a ship before creating the trip",
"datesRequired": "Please select departure and arrival dates", "datesRequired": "Please select departure and arrival dates",
"tripNameRequired": "Please enter a trip name" "tripNameRequired": "Please enter a trip name",
"startDateNotInPast": "Departure time cannot be in the past",
"endDateAfterStart": "Arrival time must be after departure time"
}, },
"createTripSuccess": "Trip created successfully!", "createTripSuccess": "Trip created successfully!",
"createTripError": "Unable to create trip. Please try again.", "createTripError": "Unable to create trip. Please try again.",
"tripAlreadyExistsError": "There is an ongoing trip that has not been completed. Please complete the current trip before creating a new one.",
"editTrip": "Edit Trip", "editTrip": "Edit Trip",
"viewTrip": "Trip Details", "viewTrip": "Trip Details",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"updateTripSuccess": "Trip updated successfully!", "updateTripSuccess": "Trip updated successfully!",
"updateTripError": "Unable to update trip. Please try again.", "updateTripError": "Unable to update trip. Please try again.",
"cancelTripConfirmTitle": "Cancel Request Confirmation",
"cancelTripConfirmMessage": "Are you sure you want to cancel the approval request? The trip will be reset to initial status.",
"cancelTripError": "Unable to cancel request. Please try again.",
"crew": { "crew": {
"title": "Crew Members", "title": "Crew Members",
"loading": "Loading crew members...", "loading": "Loading crew members...",
@@ -234,7 +246,8 @@
"title": "Trip Details", "title": "Trip Details",
"notFound": "Trip information not found", "notFound": "Trip information not found",
"basicInfo": "Basic Information", "basicInfo": "Basic Information",
"shipId": "VMS Ship Code", "shipName": "Ship Name",
"shipCode": "Ship Code",
"departureTime": "Departure Time", "departureTime": "Departure Time",
"arrivalTime": "Arrival Time", "arrivalTime": "Arrival Time",
"departurePort": "Departure Port", "departurePort": "Departure Port",
@@ -268,9 +281,9 @@
"species": "species", "species": "species",
"unknownFish": "Unknown fish", "unknownFish": "Unknown fish",
"more": "more species", "more": "more species",
"logStatusPending": "Pending", "logStatusProcessing": "Processing",
"logStatusActive": "Active", "logStatusSuccess": "Complete",
"logStatusCompleted": "Completed", "logStatusCancelled": "Cancelled",
"logStatusUnknown": "Unknown" "logStatusUnknown": "Unknown"
} }
}, },

View File

@@ -3,6 +3,7 @@
"app_name": "Hệ thống giám sát tàu cá", "app_name": "Hệ thống giám sát tàu cá",
"footer_text": "Sản phẩm của Mobifone v1.0", "footer_text": "Sản phẩm của Mobifone v1.0",
"ok": "OK", "ok": "OK",
"confirm": "Xác nhận",
"cancel": "Hủy", "cancel": "Hủy",
"done": "Xong", "done": "Xong",
"save": "Lưu", "save": "Lưu",
@@ -145,11 +146,16 @@
"costPerUnit": "Chi phí", "costPerUnit": "Chi phí",
"totalCost": "Tổng chi phí", "totalCost": "Tổng chi phí",
"tripDuration": "Thời gian chuyến đi", "tripDuration": "Thời gian chuyến đi",
"startDate": "Bắt đầu", "currentTime": "Thời gian hiện tại",
"endDate": "Kết thúc", "startDate": "Khởi hành",
"endDate": "Cập bến",
"date": "Ngày",
"time": "Giờ",
"selectDate": "Chọn ngày", "selectDate": "Chọn ngày",
"selectStartDate": "Chọn ngày bắt đầu", "selectStartDate": "Chọn ngày khởi hành",
"selectEndDate": "Chọn ngày kết thúc", "selectEndDate": "Chọn ngày cập bến",
"selectStartTime": "Chọn giờ khởi hành",
"selectEndTime": "Chọn giờ cập bến",
"portLabel": " Cảng", "portLabel": " Cảng",
"departurePort": "Cảng khởi hành", "departurePort": "Cảng khởi hành",
"arrivalPort": "Cảng cập bến", "arrivalPort": "Cảng cập bến",
@@ -178,15 +184,21 @@
"validation": { "validation": {
"shipRequired": "Vui lòng chọn tàu trước khi tạo chuyến đi", "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", "datesRequired": "Vui lòng chọn ngày khởi hành và ngày kết thúc",
"tripNameRequired": "Vui lòng nhập tên chuyến đi" "tripNameRequired": "Vui lòng nhập tên chuyến đi",
"startDateNotInPast": "Thời điểm khởi hành không được ở quá khứ",
"endDateAfterStart": "Thời điểm kết thúc phải sau thời điểm khởi hành"
}, },
"createTripSuccess": "Tạo chuyến đi thành công!", "createTripSuccess": "Tạo chuyến đi thành công!",
"createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại.", "createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại.",
"tripAlreadyExistsError": "Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới.",
"editTrip": "Chỉnh sửa chuyến đi", "editTrip": "Chỉnh sửa chuyến đi",
"viewTrip": "Chi tiết chuyến đi", "viewTrip": "Chi tiết chuyến đi",
"saveChanges": "Lưu thay đổi", "saveChanges": "Lưu thay đổi",
"updateTripSuccess": "Cập nhật chuyến đi thành công!", "updateTripSuccess": "Cập nhật chuyến đi thành công!",
"updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.", "updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.",
"cancelTripConfirmTitle": "Xác nhận hủy yêu cầu",
"cancelTripConfirmMessage": "Bạn có chắc chắn muốn hủy yêu cầu phê duyệt? Chuyến đi sẽ trở về trạng thái đã khởi tạo.",
"cancelTripError": "Không thể hủy yêu cầu. Vui lòng thử lại.",
"crew": { "crew": {
"title": "Danh sách thuyền viên", "title": "Danh sách thuyền viên",
"loading": "Đang tải danh sách thuyền viên...", "loading": "Đang tải danh sách thuyền viên...",
@@ -234,7 +246,8 @@
"title": "Chi tiết chuyến đi", "title": "Chi tiết chuyến đi",
"notFound": "Không tìm thấy thông tin chuyến đi", "notFound": "Không tìm thấy thông tin chuyến đi",
"basicInfo": "Thông tin cơ bản", "basicInfo": "Thông tin cơ bản",
"shipId": " tàu VMS", "shipName": "Tên tàu",
"shipCode": "Mã tàu",
"departureTime": "Thời gian khởi hành", "departureTime": "Thời gian khởi hành",
"arrivalTime": "Thời gian về bến", "arrivalTime": "Thời gian về bến",
"departurePort": "Cảng khởi hành", "departurePort": "Cảng khởi hành",
@@ -268,9 +281,9 @@
"species": "loài", "species": "loài",
"unknownFish": "Cá không xác định", "unknownFish": "Cá không xác định",
"more": "loài khác", "more": "loài khác",
"logStatusPending": "Chờ xử lý", "logStatusProcessing": "Đang đánh bắt",
"logStatusActive": ang thực hiện", "logStatusSuccess": ã kết thúc",
"logStatusCompleted": "Hoàn thành", "logStatusCancelled": "Đã hủy",
"logStatusUnknown": "Không xác định" "logStatusUnknown": "Không xác định"
} }
}, },

7
package-lock.json generated
View File

@@ -60,6 +60,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
@@ -6555,9 +6556,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.20", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"

View File

@@ -63,6 +63,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",

View File

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