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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,5 @@
import React from "react";
import {
View,
Text,
StyleSheet,
FlatList,
Platform,
} from "react-native";
import { View, Text, StyleSheet, FlatList, Platform } from "react-native";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import CrewCard from "./CrewCard";
@@ -43,6 +37,8 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
ListEmptyComponent={renderEmpty}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
scrollEnabled={false}
nestedScrollEnabled
/>
);
}

View File

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

View File

@@ -14,15 +14,32 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
const { colors } = useThemeContext();
const getAlertLevelColor = (level?: number) => {
const isDark =
colors.background === "#1C1C1E" ||
colors.background === "#000000" ||
colors.background?.toLowerCase().includes("1c1c1e");
switch (level) {
case 0:
return { bg: "#FEF3C7", text: "#92400E" }; // Warning - Yellow
case 1:
return { bg: "#FEE2E2", text: "#991B1B" }; // Error - Red
case 2:
return { bg: "#DBEAFE", text: "#1E40AF" }; // Info - Blue
default:
return { bg: "#F3F4F6", text: "#4B5563" }; // Default - Gray
case 0: // Bình thường - Blue/Info
return isDark
? { bg: "#172554", text: "#93C5FD" } // Dark blue
: { bg: "#DBEAFE", text: "#1E40AF" };
case 1: // Warning - Yellow
return isDark
? { bg: "#422006", text: "#FCD34D" } // Dark amber
: { bg: "#FEF3C7", text: "#92400E" };
case 2: // Error - Red
return isDark
? { bg: "#450A0A", text: "#FCA5A5" } // Dark red
: { bg: "#FEE2E2", text: "#991B1B" };
case 3: // SOS - Critical Red
return isDark
? { bg: "#7F1D1D", text: "#FFFFFF" } // Dark critical
: { bg: "#DC2626", text: "#FFFFFF" };
default: // Default - Gray
return isDark
? { bg: "#374151", text: "#D1D5DB" } // Dark gray
: { bg: "#F3F4F6", text: "#4B5563" };
}
};
@@ -38,11 +55,16 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
icon="warning-outline"
count={alerts.length}
collapsible
defaultExpanded={alerts.length > 0}
//defaultExpanded={alerts.length > 0}
defaultExpanded
>
{alerts.length === 0 ? (
<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 }]}>
{t("diary.tripDetail.noAlerts")}
</Text>
@@ -63,13 +85,22 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
size={18}
color={levelColor.text}
/>
<Text style={[styles.alertName, { color: levelColor.text }]}>
<Text
style={[styles.alertName, { color: levelColor.text }]}
>
{alert.name || t("diary.tripDetail.unknownAlert")}
</Text>
</View>
{alert.confirmed && (
<View style={[styles.confirmedBadge, { backgroundColor: colors.success || "#22C55E" }]}>
<Text style={styles.confirmedText}>{t("diary.tripDetail.confirmed")}</Text>
<View
style={[
styles.confirmedBadge,
{ backgroundColor: colors.success || "#22C55E" },
]}
>
<Text style={styles.confirmedText}>
{t("diary.tripDetail.confirmed")}
</Text>
</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 { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
import { usePort } from "@/state/use-ports";
import { useGroup } from "@/state/use-group";
import { filterPortsByProvinceCode } from "@/utils/tripDataConverters";
interface BasicInfoSectionProps {
trip: Model.Trip;
@@ -15,6 +19,55 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Get data from zustand stores
const { ships, getShip } = useShip();
const { ports, getPorts } = usePort();
const { groups, getUserGroups } = useGroup();
// Fetch data if not available
useEffect(() => {
if (!ships) {
getShip();
}
}, [ships, getShip]);
useEffect(() => {
if (!ports) {
getPorts();
}
}, [ports, getPorts]);
useEffect(() => {
if (!groups) {
getUserGroups();
}
}, [groups, getUserGroups]);
// Filter ports by province codes from groups
const filteredPorts = useMemo(() => {
return filterPortsByProvinceCode(ports, groups);
}, [ports, groups]);
// Get ship name by ship_id
const shipName = useMemo(() => {
if (!trip?.ship_id || !ships) return "--";
const ship = ships.find((s) => s.id === trip.ship_id);
return ship?.name || "--";
}, [trip?.ship_id, ships]);
// Get ship code (reg_number) by ship_id
const shipCode = useMemo(() => {
if (!trip?.ship_id || !ships) return "--";
const ship = ships.find((s) => s.id === trip.ship_id);
return ship?.reg_number || "--";
}, [trip?.ship_id, ships]);
// Get port name by ID
const getPortName = (portId: number): string => {
const port = filteredPorts.find((p) => p.id === portId);
return port?.name || "--";
};
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "--";
const date = new Date(dateStr);
@@ -30,8 +83,13 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const infoItems = [
{
icon: "boat" as const,
label: t("diary.tripDetail.shipId"),
value: trip.vms_id || "--",
label: t("diary.tripDetail.shipName"),
value: shipName,
},
{
icon: "barcode" as const,
label: t("diary.tripDetail.shipCode"),
value: shipCode,
},
{
icon: "play-circle" as const,
@@ -46,26 +104,36 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{
icon: "location" as const,
label: t("diary.tripDetail.departurePort"),
value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--",
value: getPortName(trip.departure_port_id),
},
{
icon: "flag" as const,
label: t("diary.tripDetail.arrivalPort"),
value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--",
value: getPortName(trip.arrival_port_id),
},
{
icon: "map" as const,
label: t("diary.tripDetail.fishingGrounds"),
value: trip.fishing_ground_codes?.length > 0
value:
trip.fishing_ground_codes?.length > 0
? trip.fishing_ground_codes.join(", ")
: "--",
},
];
return (
<View style={[styles.container, { backgroundColor: colors.card, borderColor: colors.separator }]}>
<View
style={[
styles.container,
{ backgroundColor: colors.card, borderColor: colors.separator },
]}
>
<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 }]}>
{t("diary.tripDetail.basicInfo")}
</Text>
@@ -74,8 +142,14 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{infoItems.map((item, index) => (
<View key={index} style={styles.infoItem}>
<View style={styles.infoLabel}>
<Ionicons name={item.icon} size={16} color={colors.textSecondary} />
<Text style={[styles.infoLabelText, { color: colors.textSecondary }]}>
<Ionicons
name={item.icon}
size={16}
color={colors.textSecondary}
/>
<Text
style={[styles.infoLabelText, { color: colors.textSecondary }]}
>
{item.label}
</Text>
</View>
@@ -106,7 +180,11 @@ const styles = StyleSheet.create({
title: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
content: {
paddingHorizontal: 16,
@@ -125,11 +203,19 @@ const styles = StyleSheet.create({
},
infoLabelText: {
fontSize: 13,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
infoValue: {
fontSize: 13,
fontWeight: "500",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

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

View File

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

View File

@@ -227,6 +227,30 @@ export default function TripFormModal({
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return false;
}
// Validate: thời điểm khởi hành phải từ hiện tại trở đi
const now = new Date();
const startMinutes = Math.floor(startDate.getTime() / 60000);
const nowMinutes = Math.floor(now.getTime() / 60000);
if (startMinutes < nowMinutes) {
Alert.alert(
t("common.error"),
t("diary.validation.startDateNotInPast") ||
"Thời điểm khởi hành không được ở quá khứ"
);
return false;
}
// Validate: thời điểm kết thúc phải sau thời điểm khởi hành
if (endDate <= startDate) {
Alert.alert(
t("common.error"),
t("diary.validation.endDateAfterStart") ||
"Thời điểm kết thúc phải sau thời điểm khởi hành"
);
return false;
}
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return false;
@@ -237,7 +261,6 @@ export default function TripFormModal({
// Build API body
const buildApiBody = useCallback((): Model.TripAPIBody => {
return {
thing_id: selectedShipId,
name: tripName,
departure_time: startDate?.toISOString() || "",
departure_port_id: departurePortId,
@@ -257,7 +280,6 @@ export default function TripFormModal({
})),
};
}, [
selectedShipId,
tripName,
startDate,
endDate,
@@ -293,10 +315,28 @@ export default function TripFormModal({
isEditMode ? "Error updating trip:" : "Error creating trip:",
error
);
// Lấy message từ server response
const serverMessage = error.response?.data || "";
// Kiểm tra lỗi cụ thể: trip already exists
if (
serverMessage.includes &&
serverMessage.includes("already exists and not completed")
) {
// Đánh dấu lỗi đã được xử lý (axios sẽ không hiển thị toast cho status 400)
Alert.alert(
t("common.warning") || "Cảnh báo",
t("diary.tripAlreadyExistsError") ||
"Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới."
);
} else {
// Các lỗi khác
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
);
}
} finally {
setIsSubmitting(false);
}
@@ -356,7 +396,10 @@ export default function TripFormModal({
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* 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 */}
<ShipSelector
selectedShipId={selectedShipId}
@@ -369,7 +412,10 @@ export default function TripFormModal({
</FormSection>
{/* Section 2: Schedule & Location */}
<FormSection title={t("diary.formSection.schedule")} icon="calendar-outline">
<FormSection
title={t("diary.formSection.schedule")}
icon="calendar-outline"
>
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
@@ -394,13 +440,27 @@ export default function TripFormModal({
</FormSection>
{/* Section 3: Equipment */}
<FormSection title={t("diary.formSection.equipment")} icon="construct-outline">
<FishingGearList items={fishingGears} onChange={setFishingGears} hideTitle />
<FormSection
title={t("diary.formSection.equipment")}
icon="construct-outline"
>
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
hideTitle
/>
</FormSection>
{/* Section 4: Costs */}
<FormSection title={t("diary.formSection.costs")} icon="wallet-outline">
<MaterialCostList items={tripCosts} onChange={setTripCosts} hideTitle />
<FormSection
title={t("diary.formSection.costs")}
icon="wallet-outline"
>
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
hideTitle
/>
</FormSection>
</ScrollView>

View File

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

View File

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

View File

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

View File

@@ -9,12 +9,19 @@ import {
API_POST_TRIP,
API_PUT_TRIP,
API_TRIP_CREW,
API_GET_TRIP_BY_ID,
API_TRIP_APPROVE_REQUEST,
API_TRIP_CANCEL_REQUEST,
} from "@/constants";
export async function queryTrip() {
return api.get<Model.Trip>(API_GET_TRIP);
}
export async function queryTripById(tripId: string) {
return api.get<Model.Trip>(`${API_GET_TRIP_BY_ID}/${tripId}`);
}
export async function queryLastTrip(thingId: string) {
return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`);
}
@@ -42,3 +49,11 @@ export async function createTrip(thingId: string, body: Model.TripAPIBody) {
export async function updateTrip(tripId: string, body: Model.TripAPIBody) {
return api.put<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";
export async function queryTripCrew(tripId: string) {
return api.get<Model.TripCrews[]>(`${API_GET_TRIP_CREW}/${tripId}`);
}
export async function searchCrew(personal_id: string) {
return api.get<Model.TripCrewPerson>(`${API_SEARCH_CREW}/${personal_id}`);
return api.get<{ trip_crews: Model.TripCrews[] }>(
`${API_GET_TRIP_CREW}/${tripId}`
);
}
export async function newTripCrew(body: Model.NewTripCrewAPIRequest) {

View File

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

View File

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

View File

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

7
package-lock.json generated
View File

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

View File

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

View File

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