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:
@@ -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;
|
||||
getThings(payloadThings);
|
||||
}, []);
|
||||
if (!things) {
|
||||
getThings(payloadThings);
|
||||
}
|
||||
}, [things, getThings]);
|
||||
|
||||
// Gọi API ships nếu chưa có dữ liệu
|
||||
const { ships, getShip } = useShip();
|
||||
useEffect(() => {
|
||||
if (!ships) {
|
||||
getShip();
|
||||
}
|
||||
}, [ships, getShip]);
|
||||
|
||||
// State cho payload trips
|
||||
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
|
||||
@@ -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) {
|
||||
router.push({
|
||||
pathname: "/trip-detail",
|
||||
params: {
|
||||
tripId: tripToView.id,
|
||||
tripData: JSON.stringify(tripToView),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Navigate to trip detail page - chỉ truyền tripId
|
||||
router.push({
|
||||
pathname: "/trip-detail",
|
||||
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 */}
|
||||
<Text style={[styles.titleText, themedStyles.titleText]}>
|
||||
{t("diary.title")}
|
||||
</Text>
|
||||
<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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
{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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
} 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);
|
||||
}, [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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,21 +380,54 @@ 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)
|
||||
await updateCrewInfo(formData.personalId.trim(), {
|
||||
name: formData.name.trim(),
|
||||
phone: formData.phone || "",
|
||||
email: formData.email || "",
|
||||
birth_date: new Date(),
|
||||
address: formData.address || "",
|
||||
});
|
||||
try {
|
||||
await updateCrewInfo(formData.personalId.trim(), {
|
||||
name: formData.name.trim(),
|
||||
phone: formData.phone || "",
|
||||
email: formData.email || "",
|
||||
birth_date: new Date(),
|
||||
address: formData.address || "",
|
||||
});
|
||||
console.log("✅ updateCrewInfo thành công");
|
||||
} catch (crewError: any) {
|
||||
console.error("❌ Lỗi updateCrewInfo:", crewError.response?.data);
|
||||
}
|
||||
|
||||
// Bước 2: Cập nhật role và note của thuyền viên trong chuyến đi
|
||||
await updateTripCrew({
|
||||
trip_id: tripId,
|
||||
personal_id: formData.personalId.trim(),
|
||||
role: formData.role as "captain" | "crew",
|
||||
note: formData.note || "",
|
||||
});
|
||||
// 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,21 +718,23 @@ export default function AddEditCrewModal({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Note */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.crew.note")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea, themedStyles.input]}
|
||||
value={formData.note}
|
||||
onChangeText={(v) => updateField("note", v)}
|
||||
placeholder={t("diary.crew.form.notePlaceholder")}
|
||||
placeholderTextColor={themedStyles.placeholder.color}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
{/* Note - Chỉ hiển thị khi edit, ẩn khi add */}
|
||||
{mode === "edit" && (
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.crew.note")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea, themedStyles.input]}
|
||||
value={formData.note}
|
||||
onChangeText={(v) => updateField("note", v)}
|
||||
placeholder={t("diary.crew.form.notePlaceholder")}
|
||||
placeholderTextColor={themedStyles.placeholder.color}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -849,7 +908,8 @@ const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
? trip.fishing_ground_codes.join(", ")
|
||||
: "--",
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
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")}
|
||||
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.dateInput, themedStyles.dateInput]}
|
||||
onPress={disabled ? undefined : handleOpenStartPicker}
|
||||
style={[styles.dateTimeInput, themedStyles.dateInput]}
|
||||
onPress={disabled ? undefined : onOpenDate}
|
||||
activeOpacity={disabled ? 1 : 0.7}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!startDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{startDate ? formatDate(startDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
{!disabled && (
|
||||
<View style={styles.inputContent}>
|
||||
<Ionicons
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* End Date */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.endDate")}
|
||||
</Text>
|
||||
{/* Time Picker */}
|
||||
<TouchableOpacity
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={disabled ? undefined : handleOpenEndPicker}
|
||||
style={[styles.dateTimeInput, themedStyles.dateInput]}
|
||||
onPress={disabled ? undefined : onOpenTime}
|
||||
activeOpacity={disabled ? 1 : 0.7}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!endDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{endDate ? formatDate(endDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
<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="calendar-outline"
|
||||
size={20}
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Start Date Picker */}
|
||||
{showStartPicker && (
|
||||
<Modal transparent animationType="fade" visible={showStartPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
|
||||
{t("diary.selectStartDate")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleConfirmStartDate}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={tempStartDate}
|
||||
mode="date"
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleStartDateChange}
|
||||
maximumDate={endDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
const isStartPicker =
|
||||
activePicker === "startDate" || activePicker === "startTime";
|
||||
const isTimePicker =
|
||||
activePicker === "startTime" || activePicker === "endTime";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.tripDuration")}
|
||||
</Text>
|
||||
|
||||
{/* Hiển thị thời gian hiện tại */}
|
||||
<View
|
||||
style={[
|
||||
styles.currentTimeContainer,
|
||||
{ backgroundColor: colors.backgroundSecondary || colors.card },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="time-outline" size={18} color={colors.primary} />
|
||||
<Text
|
||||
style={[styles.currentTimeLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
{t("diary.currentTime") || "Thời gian hiện tại"}:
|
||||
</Text>
|
||||
<Text style={[styles.currentTimeValue, { color: colors.primary }]}>
|
||||
{formatDate(currentTime)} {formatTime(currentTime)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Start Section */}
|
||||
{renderDateTimeSection(
|
||||
"start",
|
||||
startDate,
|
||||
handleOpenStartDatePicker,
|
||||
handleOpenStartTimePicker
|
||||
)}
|
||||
|
||||
{/* End Date Picker */}
|
||||
{showEndPicker && (
|
||||
<Modal transparent animationType="fade" visible={showEndPicker}>
|
||||
{/* 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={() => setShowEndPicker(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.selectEndDate")}
|
||||
{getPickerTitle()}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleConfirmEndDate}>
|
||||
<TouchableOpacity onPress={handleConfirm}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={tempEndDate}
|
||||
mode="date"
|
||||
value={isStartPicker ? tempStartDate : tempEndDate}
|
||||
mode={isTimePicker ? "time" : "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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
Alert.alert(
|
||||
t("common.error"),
|
||||
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
|
||||
);
|
||||
|
||||
// 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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
2
controller/typings.d.ts
vendored
2
controller/typings.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "Mã 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
7
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user