thêm tab "Xem chi tiết chuyến đi", "Xem chi tiết thành viên chuyến đi", tái sử dụng lại components modal tripForm

This commit is contained in:
2025-12-23 23:10:19 +07:00
parent afc6acbfe2
commit 000a4ed856
22 changed files with 3221 additions and 379 deletions

View File

@@ -2,15 +2,14 @@ import { Tabs, useSegments } from "expo-router";
import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { queryProfile } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n";
import { useColorScheme } from "@/hooks/use-theme-context";
import { useThemeContext } from "@/hooks/use-theme-context";
import { addUserStorage } from "@/utils/storage";
import { useEffect, useRef } from "react";
export default function TabLayout() {
const colorScheme = useColorScheme();
const { colors, colorScheme } = useThemeContext();
const segments = useSegments() as string[];
const prev = useRef<string | null>(null);
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
@@ -51,9 +50,18 @@ export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
tabBarActiveTintColor: colors.tint,
headerShown: false,
tabBarButton: HapticTab,
// Set tab bar styles based on theme - prevents white flash
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.separator,
},
// Set screen content background
sceneStyle: {
backgroundColor: colors.background,
},
}}
>
<Tabs.Screen

View File

@@ -10,6 +10,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard";
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
@@ -23,10 +24,10 @@ import { useThemeContext } from "@/hooks/use-theme-context";
export default function diary() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
const [showFilterModal, setShowFilterModal] = useState(false);
const [showAddTripModal, setShowAddTripModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
const [viewingTrip, setViewingTrip] = useState<Model.Trip | null>(null);
const [filters, setFilters] = useState<FilterValues>({
status: null,
startDate: null,
@@ -41,6 +42,10 @@ 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,
@@ -55,6 +60,8 @@ export default function diary() {
// Gọi API things
const { getThings } = useThings();
useEffect(() => {
if (hasInitializedThings.current) return;
hasInitializedThings.current = true;
getThings(payloadThings);
}, []);
@@ -79,10 +86,10 @@ export default function diary() {
const { tripsList, getTripsList, loading } = useTripsList();
// console.log("Payload trips:", payloadTrips);
// Gọi API trips lần đầu
useEffect(() => {
if (hasInitializedTrips.current) return;
hasInitializedTrips.current = true;
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
@@ -157,7 +164,11 @@ export default function diary() {
// Hàm load more data khi scroll đến cuối
const handleLoadMore = useCallback(() => {
if (isLoadingMore || !hasMore) {
// Không load more nếu:
// - Đang loading (ban đầu hoặc loadMore)
// - Không còn data để load
// - Chưa có data nào (tránh FlatList tự trigger khi list rỗng)
if (isLoadingMore || loading || !hasMore || allTrips.length === 0) {
return;
}
@@ -169,7 +180,7 @@ export default function diary() {
};
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
}, [isLoadingMore, hasMore, payloadTrips]);
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
// const handleTripPress = (tripId: string) => {
// // TODO: Navigate to trip detail
@@ -177,11 +188,16 @@ export default function diary() {
// };
const handleViewTrip = (tripId: string) => {
// Find the trip from allTrips and open modal in view mode
// Navigate to trip detail page instead of opening modal
const tripToView = allTrips.find((trip) => trip.id === tripId);
if (tripToView) {
setViewingTrip(tripToView);
setShowAddTripModal(true);
router.push({
pathname: "/trip-detail",
params: {
tripId: tripToView.id,
tripData: JSON.stringify(tripToView)
},
});
}
};
@@ -195,8 +211,13 @@ export default function diary() {
};
const handleViewTeam = (tripId: string) => {
console.log("View team:", tripId);
// TODO: Navigate to team management
const trip = allTrips.find((t) => t.id === tripId);
if (trip) {
router.push({
pathname: "/trip-crew",
params: { tripId: trip.id, tripName: trip.name || "" },
});
}
};
const handleSendTrip = (tripId: string) => {
@@ -260,7 +281,13 @@ export default function diary() {
onDelete={() => handleDeleteTrip(item.id)}
/>
),
[handleViewTrip, handleEditTrip, handleViewTeam, handleSendTrip, handleDeleteTrip]
[
handleViewTrip,
handleEditTrip,
handleViewTeam,
handleSendTrip,
handleDeleteTrip,
]
);
// Key extractor cho FlatList
@@ -301,10 +328,15 @@ export default function diary() {
};
return (
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
<SafeAreaView
style={[styles.safeArea, themedStyles.safeArea]}
edges={["top"]}
>
<View style={styles.container}>
{/* Header */}
<Text style={[styles.titleText, themedStyles.titleText]}>{t("diary.title")}</Text>
<Text style={[styles.titleText, themedStyles.titleText]}>
{t("diary.title")}
</Text>
{/* Filter & Add Button Row */}
<View style={styles.actionRow}>
@@ -358,17 +390,16 @@ export default function diary() {
onApply={handleApplyFilters}
/>
{/* Add/Edit/View Trip Modal */}
{/* Add/Edit Trip Modal */}
<AddTripModal
visible={showAddTripModal}
onClose={() => {
setShowAddTripModal(false);
setEditingTrip(null);
setViewingTrip(null);
}}
onSuccess={handleTripAddSuccess}
mode={viewingTrip ? 'view' : editingTrip ? 'edit' : 'add'}
tripData={viewingTrip || editingTrip || undefined}
mode={editingTrip ? "edit" : "add"}
tripData={editingTrip || undefined}
/>
</SafeAreaView>
);
@@ -394,17 +425,10 @@ const styles = StyleSheet.create({
}),
},
actionRow: {
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "center",
gap: 12,
marginBottom: 12,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 20,
gap: 12,
marginBottom: 12,
},
countText: {
@@ -421,7 +445,7 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 8,
height: 40,
borderRadius: 8,
gap: 6,
},

View File

@@ -2,14 +2,14 @@ import {
DarkTheme,
DefaultTheme,
ThemeProvider,
Theme,
} from "@react-navigation/native";
import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { View, StyleSheet } from "react-native";
import "react-native-reanimated";
// import Toast from "react-native-toast-message";
// import { toastConfig } from "@/config";
import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth";
import "@/global.css";
@@ -18,21 +18,49 @@ import {
ThemeProvider as AppThemeProvider,
useThemeContext,
} from "@/hooks/use-theme-context";
import { Colors } from "@/constants/theme";
import Toast from "react-native-toast-message";
import "../global.css";
function AppContent() {
const router = useRouter();
const { colorScheme } = useThemeContext();
console.log("Color Scheme: ", colorScheme);
const { colorScheme, colors } = useThemeContext();
useEffect(() => {
setRouterInstance(router);
}, [router]);
// Create custom navigation theme that uses our app colors
// This ensures the navigation container background matches our theme
const navigationTheme: Theme = useMemo(() => {
const baseTheme = colorScheme === "dark" ? DarkTheme : DefaultTheme;
return {
...baseTheme,
colors: {
...baseTheme.colors,
background: colors.background,
card: colors.card,
text: colors.text,
border: colors.border,
primary: colors.primary,
},
};
}, [colorScheme, colors]);
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
// Wrap entire app with View that has themed background
// This prevents any white flash during screen transitions
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ThemeProvider value={navigationTheme}>
<Stack
screenOptions={{ headerShown: false }}
screenOptions={{
headerShown: false,
// Set content background to match theme
contentStyle: {
backgroundColor: colors.background,
},
// Animation settings for smoother transitions
animation: "slide_from_right",
}}
initialRouteName="auth/login"
>
<Stack.Screen
@@ -51,17 +79,40 @@ function AppContent() {
}}
/>
<Stack.Screen
name="trip-detail"
options={{
title: "Trip Detail",
headerShown: false,
}}
/>
<Stack.Screen
name="trip-crew"
options={{
title: "Trip Crew",
headerShown: false,
}}
/>
<Stack.Screen
name="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style="auto" />
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default function RootLayout() {
return (
<I18nProvider>

283
app/trip-crew.tsx Normal file
View File

@@ -0,0 +1,283 @@
import React, { useEffect, useState, useCallback } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
ActivityIndicator,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { queryTripCrew } from "@/controller/TripCrewController";
import CrewList from "@/components/diary/TripCrewModal/CrewList";
import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal";
export default function TripCrewPage() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
const { tripId, tripName } = useLocalSearchParams<{ tripId: string; tripName?: string }>();
// State
const [loading, setLoading] = useState(false);
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
const [error, setError] = useState<string | null>(null);
// Add/Edit modal state
const [showAddEditModal, setShowAddEditModal] = useState(false);
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(null);
// Fetch crew data
const fetchCrewData = useCallback(async () => {
if (!tripId) return;
setLoading(true);
setError(null);
try {
const response = await queryTripCrew(tripId);
const data = response.data as any;
if (data?.trip_crews && Array.isArray(data.trip_crews)) {
setCrews(data.trip_crews);
} else if (Array.isArray(data)) {
setCrews(data);
} else {
setCrews([]);
}
} catch (err) {
console.error("Error fetching crew:", err);
setError(t("diary.crew.fetchError"));
setCrews([]);
} finally {
setLoading(false);
}
}, [tripId, t]);
useEffect(() => {
fetchCrewData();
}, [fetchCrewData]);
// Add crew handler
const handleAddCrew = () => {
setEditingCrew(null);
setShowAddEditModal(true);
};
// Edit crew handler
const handleEditCrew = (crew: Model.TripCrews) => {
setEditingCrew(crew);
setShowAddEditModal(true);
};
// Delete crew handler
const handleDeleteCrew = (crew: Model.TripCrews) => {
Alert.alert(
t("diary.crew.deleteConfirmTitle"),
t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
// TODO: Call delete API when available
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
},
},
]
);
};
// Save crew handler
const handleSaveCrew = async (formData: any) => {
// TODO: Call API to add/edit crew when available
await fetchCrewData();
};
const themedStyles = {
container: { backgroundColor: colors.background },
header: { backgroundColor: colors.card, borderBottomColor: colors.separator },
title: { color: colors.text },
subtitle: { color: colors.textSecondary },
};
return (
<SafeAreaView style={[styles.container, themedStyles.container]} edges={["top"]}>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.crew.title")}
</Text>
{tripName && (
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
{tripName}
</Text>
)}
</View>
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
<Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{/* Content */}
<View style={styles.content}>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.crew.loading")}
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: colors.primary }]}
onPress={fetchCrewData}
>
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
</TouchableOpacity>
</View>
) : (
<CrewList crews={crews} onEdit={handleEditCrew} onDelete={handleDeleteCrew} />
)}
</View>
{/* Footer - Crew count */}
<View style={[styles.footer, { backgroundColor: colors.card, borderTopColor: colors.separator }]}>
<View style={styles.countContainer}>
<Ionicons name="people-outline" size={20} color={colors.primary} />
<Text style={[styles.countText, { color: colors.text }]}>
{t("diary.crew.totalMembers", { count: crews.length })}
</Text>
</View>
</View>
{/* Add/Edit Crew Modal */}
<AddEditCrewModal
visible={showAddEditModal}
onClose={() => {
setShowAddEditModal(false);
setEditingCrew(null);
}}
onSave={handleSaveCrew}
mode={editingCrew ? "edit" : "add"}
initialData={
editingCrew
? {
personalId: editingCrew.PersonalID,
name: editingCrew.Person?.name || "",
phone: editingCrew.Person?.phone || "",
email: editingCrew.Person?.email || "",
address: editingCrew.Person?.address || "",
role: editingCrew.role || "crew",
note: editingCrew.note || "",
}
: undefined
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
backButton: {
padding: 4,
},
addButton: {
padding: 4,
},
headerTitles: {
flex: 1,
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
subtitle: {
fontSize: 13,
marginTop: 2,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
content: {
flex: 1,
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
paddingHorizontal: 20,
},
errorText: {
fontSize: 14,
textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
retryButton: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: "#FFFFFF",
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
footer: {
borderTopWidth: 1,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 30,
},
countContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
},
countText: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
});

312
app/trip-detail.tsx Normal file
View File

@@ -0,0 +1,312 @@
import React, { useEffect, useState, useMemo } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
ScrollView,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { TRIP_STATUS_CONFIG } from "@/components/diary/types";
import {
convertFishingGears,
convertTripCosts,
} from "@/utils/tripDataConverters";
// Reuse existing components
import CrewList from "@/components/diary/TripCrewModal/CrewList";
import FishingGearList from "@/components/diary/TripFormModal/FishingGearList";
import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList";
// Section components
import {
SectionCard,
AlertsSection,
FishingLogsSection,
BasicInfoSection,
} from "@/components/diary/TripDetailSections";
export default function TripDetailPage() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
const { tripId, tripData: tripDataParam } = 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
// Parse trip data from params or fetch from API
useEffect(() => {
if (tripDataParam) {
try {
const parsedTrip = JSON.parse(tripDataParam) as Model.Trip;
setTrip(parsedTrip);
} catch (e) {
console.error("Error parsing trip data:", e);
}
}
// TODO: Fetch trip detail from API using tripId if not passed via params
setLoading(false);
}, [tripDataParam, tripId]);
// Convert trip data to component format using memoization
const fishingGears = useMemo(
() => convertFishingGears(trip?.fishing_gears, "view"),
[trip?.fishing_gears]
);
const tripCosts = useMemo(
() => convertTripCosts(trip?.trip_cost, "view"),
[trip?.trip_cost]
);
const statusConfig = useMemo(() => {
const status = trip?.trip_status ?? 0;
return TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] || TRIP_STATUS_CONFIG[0];
}, [trip?.trip_status]);
// Empty section component
const EmptySection = ({ icon, message }: { icon: string; message: string }) => (
<View style={styles.emptySection}>
<Ionicons name={icon as any} size={40} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{message}
</Text>
</View>
);
// Render loading state
if (loading) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("common.loading")}
</Text>
</View>
</SafeAreaView>
);
}
// Render error state
if (!trip) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
<Header title={t("diary.tripDetail.title")} onBack={() => router.back()} colors={colors} />
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
<Text style={[styles.errorText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.notFound")}
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
{/* Header with status badge */}
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{trip.name || t("diary.tripDetail.title")}
</Text>
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
<Ionicons name={statusConfig.icon as any} size={12} color={statusConfig.textColor} />
<Text style={[styles.statusText, { color: statusConfig.textColor }]}>
{statusConfig.label}
</Text>
</View>
</View>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* Basic Info */}
<BasicInfoSection trip={trip} />
{/* Alerts */}
<AlertsSection alerts={alerts} />
{/* Trip Costs */}
<SectionCard
title={t("diary.tripDetail.costs")}
icon="wallet-outline"
count={tripCosts.length}
collapsible
defaultExpanded
>
{tripCosts.length > 0 ? (
<View style={styles.sectionInnerContent}>
<MaterialCostList items={tripCosts} onChange={() => {}} disabled />
</View>
) : (
<EmptySection icon="receipt-outline" message={t("diary.tripDetail.noCosts")} />
)}
</SectionCard>
{/* Fishing Gears */}
<SectionCard
title={t("diary.tripDetail.gears")}
icon="construct-outline"
count={fishingGears.length}
collapsible
defaultExpanded
>
{fishingGears.length > 0 ? (
<View style={styles.sectionInnerContent}>
<FishingGearList items={fishingGears} onChange={() => {}} disabled />
</View>
) : (
<EmptySection icon="build-outline" message={t("diary.tripDetail.noGears")} />
)}
</SectionCard>
{/* Crew List */}
<SectionCard
title={t("diary.tripDetail.crew")}
icon="people-outline"
count={trip.crews?.length || 0}
collapsible
defaultExpanded
>
{trip.crews && trip.crews.length > 0 ? (
<CrewList crews={trip.crews} />
) : (
<EmptySection icon="person-add-outline" message={t("diary.tripDetail.noCrew")} />
)}
</SectionCard>
{/* Fishing Logs */}
<FishingLogsSection fishingLogs={trip.fishing_logs} />
</ScrollView>
</SafeAreaView>
);
}
// Header component for reuse
function Header({
title,
onBack,
colors,
}: {
title: string;
onBack: () => void;
colors: any;
}) {
return (
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}>
<TouchableOpacity onPress={onBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
</View>
<View style={styles.placeholder} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
backButton: {
padding: 4,
},
headerTitles: {
flex: 1,
alignItems: "center",
gap: 6,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 11,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},
contentContainer: {
padding: 16,
paddingBottom: 40,
},
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
paddingHorizontal: 20,
},
errorText: {
fontSize: 14,
textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
sectionInnerContent: {
marginTop: -8,
},
emptySection: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 24,
gap: 8,
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
});

View File

@@ -0,0 +1,444 @@
import React, { useState, useEffect } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
TextInput,
ScrollView,
ActivityIndicator,
Animated,
Dimensions,
Alert,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface CrewFormData {
personalId: string;
name: string;
phone: string;
email: string;
address: string;
role: string;
note: string;
}
interface AddEditCrewModalProps {
visible: boolean;
onClose: () => void;
onSave: (data: CrewFormData) => Promise<void>;
mode: "add" | "edit";
initialData?: Partial<CrewFormData>;
}
const ROLES = ["captain", "crew", "engineer", "cook"];
export default function AddEditCrewModal({
visible,
onClose,
onSave,
mode,
initialData,
}: AddEditCrewModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Animation values
const fadeAnim = useState(new Animated.Value(0))[0];
const slideAnim = useState(new Animated.Value(Dimensions.get("window").height))[0];
// Form state
const [formData, setFormData] = useState<CrewFormData>({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Pre-fill form when editing
useEffect(() => {
if (visible && initialData) {
setFormData({
personalId: initialData.personalId || "",
name: initialData.name || "",
phone: initialData.phone || "",
email: initialData.email || "",
address: initialData.address || "",
role: initialData.role || "crew",
note: initialData.note || "",
});
} else if (visible && mode === "add") {
// Reset form for add mode
setFormData({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
}
}, [visible, initialData, mode]);
// Handle animation
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
const handleClose = () => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
onClose();
});
};
const handleSave = async () => {
// Validate required fields
if (!formData.personalId.trim()) {
Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired"));
return;
}
if (!formData.name.trim()) {
Alert.alert(t("common.error"), t("diary.crew.form.nameRequired"));
return;
}
setIsSubmitting(true);
try {
await onSave(formData);
handleClose();
} catch (error) {
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
} finally {
setIsSubmitting(false);
}
};
const updateField = (field: keyof CrewFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const themedStyles = {
modalContainer: { backgroundColor: colors.card },
header: { borderBottomColor: colors.separator },
title: { color: colors.text },
label: { color: colors.text },
input: {
backgroundColor: colors.backgroundSecondary,
color: colors.text,
borderColor: colors.separator,
},
placeholder: { color: colors.textSecondary },
roleButton: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.separator,
},
roleButtonActive: {
backgroundColor: colors.primary + "20",
borderColor: colors.primary,
},
roleText: { color: colors.textSecondary },
roleTextActive: { color: colors.primary },
};
return (
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{mode === "add" ? t("diary.crew.form.addTitle") : t("diary.crew.form.editTitle")}
</Text>
<View style={styles.headerPlaceholder} />
</View>
{/* Content */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* Personal ID */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.personalId")} *
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.personalId}
onChangeText={(v) => updateField("personalId", v)}
placeholder={t("diary.crew.form.personalIdPlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
editable={mode === "add"}
/>
</View>
{/* Name */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.name")} *
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.name}
onChangeText={(v) => updateField("name", v)}
placeholder={t("diary.crew.form.namePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</View>
{/* Phone */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.phone")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.phone}
onChangeText={(v) => updateField("phone", v)}
placeholder={t("diary.crew.form.phonePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
keyboardType="phone-pad"
/>
</View>
{/* Role */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.role")}
</Text>
<View style={styles.roleContainer}>
{ROLES.map((role) => (
<TouchableOpacity
key={role}
style={[
styles.roleButton,
themedStyles.roleButton,
formData.role === role && themedStyles.roleButtonActive,
]}
onPress={() => updateField("role", role)}
>
<Text
style={[
styles.roleButtonText,
themedStyles.roleText,
formData.role === role && themedStyles.roleTextActive,
]}
>
{t(`diary.crew.roles.${role}`)}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Address */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.address")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.address}
onChangeText={(v) => updateField("address", v)}
placeholder={t("diary.crew.form.addressPlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</View>
{/* Note */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.note")}
</Text>
<TextInput
style={[styles.input, styles.textArea, themedStyles.input]}
value={formData.note}
onChangeText={(v) => updateField("note", v)}
placeholder={t("diary.crew.form.notePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
multiline
numberOfLines={3}
/>
</View>
</ScrollView>
{/* Footer */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
<TouchableOpacity
style={[styles.cancelButton, { backgroundColor: colors.backgroundSecondary }]}
onPress={handleClose}
>
<Text style={[styles.cancelButtonText, { color: colors.textSecondary }]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.saveButton,
{ backgroundColor: colors.primary },
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSave}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.saveButtonText}>{t("common.save")}</Text>
)}
</TouchableOpacity>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
headerPlaceholder: {
width: 32,
},
content: {
padding: 20,
},
formGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginBottom: 8,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
input: {
height: 44,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 14,
fontSize: 15,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
textArea: {
height: 80,
paddingTop: 12,
textAlignVertical: "top",
},
roleContainer: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
roleButton: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
roleButtonText: {
fontSize: 13,
fontWeight: "500",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
footer: {
flexDirection: "row",
gap: 12,
padding: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
saveButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
buttonDisabled: {
opacity: 0.7,
},
});

View File

@@ -0,0 +1,285 @@
import React, { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Platform,
Image,
ActivityIndicator,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import dayjs from "dayjs";
import { queryCrewImage } from "@/controller/TripCrewController";
import { Buffer } from "buffer";
interface CrewCardProps {
crew: Model.TripCrews;
onEdit?: (crew: Model.TripCrews) => void;
onDelete?: (crew: Model.TripCrews) => void;
}
export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
const { colors } = useThemeContext();
const { t } = useI18n();
const person = crew.Person;
const joinedDate = crew.joined_at ? dayjs(crew.joined_at).format("DD/MM/YYYY") : "-";
const leftDate = crew.left_at ? dayjs(crew.left_at).format("DD/MM/YYYY") : null;
// State for image
const [imageUri, setImageUri] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(true);
const [imageError, setImageError] = useState(false);
// Fetch crew image
useEffect(() => {
const fetchImage = async () => {
if (!person?.personal_id) {
setImageLoading(false);
setImageError(true);
return;
}
try {
const response = await queryCrewImage(person.personal_id);
if (response.data) {
// Convert arraybuffer to base64
const base64 = Buffer.from(response.data as ArrayBuffer).toString("base64");
setImageUri(`data:image/jpeg;base64,${base64}`);
} else {
setImageError(true);
}
} catch (err) {
setImageError(true);
} finally {
setImageLoading(false);
}
};
fetchImage();
}, [person?.personal_id]);
const themedStyles = {
card: {
backgroundColor: colors.card,
borderColor: colors.separator,
},
name: {
color: colors.text,
},
role: {
color: colors.primary,
backgroundColor: colors.primary + "20",
},
label: {
color: colors.textSecondary,
},
value: {
color: colors.text,
},
iconColor: colors.textSecondary,
imagePlaceholder: {
backgroundColor: colors.backgroundSecondary,
},
};
return (
<View style={[styles.card, themedStyles.card]}>
{/* Left Image Section (1/3 width) */}
<View style={[styles.imageSection, themedStyles.imagePlaceholder]}>
{imageLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : imageUri && !imageError ? (
<Image
source={{ uri: imageUri }}
style={styles.crewImage}
resizeMode="cover"
/>
) : (
<View style={styles.imagePlaceholder}>
<Ionicons name="person" size={40} color={colors.textSecondary} />
</View>
)}
</View>
{/* Right Content Section (2/3 width) */}
<View style={styles.contentSection}>
{/* Name & Role */}
<View style={styles.header}>
<Text style={[styles.name, themedStyles.name]} numberOfLines={1}>
{person?.name || "-"}
</Text>
<View style={[styles.roleBadge, { backgroundColor: themedStyles.role.backgroundColor }]}>
<Text style={[styles.roleText, { color: themedStyles.role.color }]}>
{crew.role || t("diary.crew.member")}
</Text>
</View>
</View>
{/* Info Grid */}
<View style={styles.infoGrid}>
{/* Phone */}
<View style={styles.infoRow}>
<Ionicons name="call-outline" size={14} color={themedStyles.iconColor} />
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
{person?.phone || "-"}
</Text>
</View>
{/* Personal ID */}
<View style={styles.infoRow}>
<Ionicons name="card-outline" size={14} color={themedStyles.iconColor} />
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
{person?.personal_id || "-"}
</Text>
</View>
{/* Joined Date */}
<View style={styles.infoRow}>
<Ionicons name="calendar-outline" size={14} color={themedStyles.iconColor} />
<Text style={[styles.value, themedStyles.value]}>{joinedDate}</Text>
</View>
{/* Left Date (only show if exists) */}
{leftDate && (
<View style={styles.infoRow}>
<Ionicons name="exit-outline" size={14} color={themedStyles.iconColor} />
<Text style={[styles.value, themedStyles.value]}>{leftDate}</Text>
</View>
)}
</View>
{/* Action Buttons */}
{(onEdit || onDelete) && (
<View style={styles.actionRow}>
{onEdit && (
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.primary + "15" }]}
onPress={() => onEdit(crew)}
>
<Ionicons name="pencil-outline" size={14} color={colors.primary} />
<Text style={[styles.actionText, { color: colors.primary }]}>
{t("common.edit")}
</Text>
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: (colors.error || "#FF3B30") + "15" }]}
onPress={() => onDelete(crew)}
>
<Ionicons name="trash-outline" size={14} color={colors.error || "#FF3B30"} />
<Text style={[styles.actionText, { color: colors.error || "#FF3B30" }]}>
{t("common.delete")}
</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: "row",
borderRadius: 12,
borderWidth: 1,
marginBottom: 12,
overflow: "hidden",
maxHeight: 150,
},
imageSection: {
width: 130,
alignSelf: "stretch",
justifyContent: "center",
alignItems: "center",
},
crewImage: {
width: "100%",
height: "100%",
},
imagePlaceholder: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
},
contentSection: {
flex: 1,
padding: 10,
justifyContent: "center",
},
header: {
marginBottom: 8,
},
name: {
fontSize: 17,
fontWeight: "700",
marginBottom: 4,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
roleBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
roleText: {
fontSize: 12,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
infoGrid: {
gap: 4,
},
infoRow: {
flexDirection: "row",
alignItems: "center",
gap: 5,
},
value: {
fontSize: 14,
fontWeight: "500",
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
actionRow: {
flexDirection: "row",
gap: 8,
marginTop: 8,
},
actionButton: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
actionText: {
fontSize: 12,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,69 @@
import React from "react";
import {
View,
Text,
StyleSheet,
FlatList,
Platform,
} from "react-native";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import CrewCard from "./CrewCard";
interface CrewListProps {
crews: Model.TripCrews[];
onEdit?: (crew: Model.TripCrews) => void;
onDelete?: (crew: Model.TripCrews) => void;
}
export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
const { colors } = useThemeContext();
const { t } = useI18n();
const renderItem = ({ item }: { item: Model.TripCrews }) => (
<CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} />
);
const keyExtractor = (item: Model.TripCrews, index: number) =>
`${item.PersonalID}-${index}`;
const renderEmpty = () => (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.crew.noCrewMembers")}
</Text>
</View>
);
return (
<FlatList
data={crews}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListEmptyComponent={renderEmpty}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
);
}
const styles = StyleSheet.create({
listContent: {
paddingBottom: 20,
flexGrow: 1,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 60,
},
emptyText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,377 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ActivityIndicator,
Animated,
Dimensions,
Alert,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { queryTripCrew } from "@/controller/TripCrewController";
import CrewList from "./CrewList";
import AddEditCrewModal from "./AddEditCrewModal";
interface TripCrewModalProps {
visible: boolean;
onClose: () => void;
tripId: string | null;
tripName?: string;
}
export default function TripCrewModal({
visible,
onClose,
tripId,
tripName,
}: TripCrewModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
// State
const [loading, setLoading] = useState(false);
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
const [error, setError] = useState<string | null>(null);
// Add/Edit modal state
const [showAddEditModal, setShowAddEditModal] = useState(false);
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(null);
// Fetch crew data when modal opens
useEffect(() => {
if (visible && tripId) {
fetchCrewData();
}
}, [visible, tripId]);
// Handle animation when modal visibility changes
useEffect(() => {
if (visible) {
setError(null);
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
const fetchCrewData = async () => {
if (!tripId) return;
setLoading(true);
setError(null);
try {
const response = await queryTripCrew(tripId);
const data = response.data as any;
if (data?.trip_crews && Array.isArray(data.trip_crews)) {
setCrews(data.trip_crews);
} else if (Array.isArray(data)) {
setCrews(data);
} else {
setCrews([]);
}
} catch (err) {
console.error("Error fetching crew:", err);
setError(t("diary.crew.fetchError"));
setCrews([]);
} finally {
setLoading(false);
}
};
const handleClose = () => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
onClose();
setTimeout(() => {
setCrews([]);
setError(null);
}, 100);
});
};
// Add crew handler
const handleAddCrew = () => {
setEditingCrew(null);
setShowAddEditModal(true);
};
// Edit crew handler
const handleEditCrew = (crew: Model.TripCrews) => {
setEditingCrew(crew);
setShowAddEditModal(true);
};
// Delete crew handler
const handleDeleteCrew = (crew: Model.TripCrews) => {
Alert.alert(
t("diary.crew.deleteConfirmTitle"),
t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: async () => {
// TODO: Call delete API when available
// For now, just remove from local state
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
},
},
]
);
};
// Save crew handler (add or edit)
const handleSaveCrew = async (formData: any) => {
// TODO: Call API to add/edit crew when available
// For now, refresh the list
await fetchCrewData();
};
const themedStyles = {
modalContainer: { backgroundColor: colors.card },
header: { borderBottomColor: colors.separator },
title: { color: colors.text },
subtitle: { color: colors.textSecondary },
content: { backgroundColor: colors.background },
};
return (
<>
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.crew.title")}
</Text>
{tripName && (
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
{tripName}
</Text>
)}
</View>
{/* Add Button */}
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
<Ionicons name="add" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
{/* Content */}
<View style={[styles.content, themedStyles.content]}>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.crew.loading")}
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}>
{error}
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: colors.primary }]}
onPress={fetchCrewData}
>
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
</TouchableOpacity>
</View>
) : (
<CrewList
crews={crews}
onEdit={handleEditCrew}
onDelete={handleDeleteCrew}
/>
)}
</View>
{/* Footer - Crew count */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
<View style={styles.countContainer}>
<Ionicons name="people-outline" size={20} color={colors.primary} />
<Text style={[styles.countText, { color: colors.text }]}>
{t("diary.crew.totalMembers", { count: crews.length })}
</Text>
</View>
</View>
</Animated.View>
</Animated.View>
</Modal>
{/* Add/Edit Crew Modal */}
<AddEditCrewModal
visible={showAddEditModal}
onClose={() => {
setShowAddEditModal(false);
setEditingCrew(null);
}}
onSave={handleSaveCrew}
mode={editingCrew ? "edit" : "add"}
initialData={
editingCrew
? {
personalId: editingCrew.PersonalID,
name: editingCrew.Person?.name || "",
phone: editingCrew.Person?.phone || "",
email: editingCrew.Person?.email || "",
address: editingCrew.Person?.address || "",
role: editingCrew.role || "crew",
note: editingCrew.note || "",
}
: undefined
}
/>
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "92%",
minHeight: "80%",
shadowColor: "#000",
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
addButton: {
padding: 4,
},
headerTitles: {
flex: 1,
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
subtitle: {
fontSize: 13,
marginTop: 2,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
content: {
flex: 1,
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
paddingHorizontal: 20,
},
errorText: {
fontSize: 14,
textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
retryButton: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: "#FFFFFF",
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
footer: {
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 16,
},
countContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
},
countText: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
});

View File

@@ -0,0 +1,156 @@
import React from "react";
import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import SectionCard from "./SectionCard";
interface AlertsSectionProps {
alerts?: Model.Alarm[];
}
export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const getAlertLevelColor = (level?: number) => {
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
}
};
const formatTime = (timestamp?: number) => {
if (!timestamp) return "--";
const date = new Date(timestamp * 1000);
return date.toLocaleString("vi-VN");
};
return (
<SectionCard
title={t("diary.tripDetail.alerts")}
icon="warning-outline"
count={alerts.length}
collapsible
defaultExpanded={alerts.length > 0}
>
{alerts.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="checkmark-circle-outline" size={40} color={colors.success || "#22C55E"} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noAlerts")}
</Text>
</View>
) : (
<View style={styles.list}>
{alerts.map((alert, index) => {
const levelColor = getAlertLevelColor(alert.level);
return (
<View
key={alert.id || index}
style={[styles.alertItem, { backgroundColor: levelColor.bg }]}
>
<View style={styles.alertHeader}>
<View style={styles.alertInfo}>
<Ionicons
name={alert.level === 1 ? "alert-circle" : "warning"}
size={18}
color={levelColor.text}
/>
<Text style={[styles.alertName, { color: levelColor.text }]}>
{alert.name || t("diary.tripDetail.unknownAlert")}
</Text>
</View>
{alert.confirmed && (
<View style={[styles.confirmedBadge, { backgroundColor: colors.success || "#22C55E" }]}>
<Text style={styles.confirmedText}>{t("diary.tripDetail.confirmed")}</Text>
</View>
)}
</View>
<Text style={[styles.alertTime, { color: levelColor.text }]}>
{formatTime(alert.time)}
</Text>
</View>
);
})}
</View>
)}
</SectionCard>
);
}
const styles = StyleSheet.create({
emptyContainer: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 24,
gap: 8,
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
list: {
gap: 10,
},
alertItem: {
padding: 12,
borderRadius: 8,
},
alertHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 4,
},
alertInfo: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flex: 1,
},
alertName: {
fontSize: 14,
fontWeight: "600",
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
alertTime: {
fontSize: 12,
marginLeft: 26,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
confirmedBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
confirmedText: {
color: "#FFFFFF",
fontSize: 11,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,135 @@
import React 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";
interface BasicInfoSectionProps {
trip: Model.Trip;
}
/**
* Displays basic trip information like ship, dates, ports
*/
export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "--";
const date = new Date(dateStr);
return date.toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const infoItems = [
{
icon: "boat" as const,
label: t("diary.tripDetail.shipId"),
value: trip.vms_id || "--",
},
{
icon: "play-circle" as const,
label: t("diary.tripDetail.departureTime"),
value: formatDateTime(trip.departure_time),
},
{
icon: "stop-circle" as const,
label: t("diary.tripDetail.arrivalTime"),
value: formatDateTime(trip.arrival_time),
},
{
icon: "location" as const,
label: t("diary.tripDetail.departurePort"),
value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--",
},
{
icon: "flag" as const,
label: t("diary.tripDetail.arrivalPort"),
value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--",
},
{
icon: "map" as const,
label: t("diary.tripDetail.fishingGrounds"),
value: trip.fishing_ground_codes?.length > 0
? trip.fishing_ground_codes.join(", ")
: "--",
},
];
return (
<View style={[styles.container, { backgroundColor: colors.card, borderColor: colors.separator }]}>
<View style={styles.header}>
<Ionicons name="information-circle-outline" size={20} color={colors.primary} />
<Text style={[styles.title, { color: colors.text }]}>
{t("diary.tripDetail.basicInfo")}
</Text>
</View>
<View style={styles.content}>
{infoItems.map((item, index) => (
<View key={index} style={styles.infoItem}>
<View style={styles.infoLabel}>
<Ionicons name={item.icon} size={16} color={colors.textSecondary} />
<Text style={[styles.infoLabelText, { color: colors.textSecondary }]}>
{item.label}
</Text>
</View>
<Text style={[styles.infoValue, { color: colors.text }]}>
{item.value}
</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 12,
borderWidth: 1,
marginBottom: 16,
overflow: "hidden",
},
header: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 14,
},
title: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
content: {
paddingHorizontal: 16,
paddingBottom: 16,
gap: 12,
},
infoItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
infoLabel: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
infoLabelText: {
fontSize: 13,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
infoValue: {
fontSize: 13,
fontWeight: "500",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
});

View File

@@ -0,0 +1,356 @@
import React from "react";
import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useI18n } from "@/hooks/use-i18n";
import SectionCard from "./SectionCard";
interface FishingLogsSectionProps {
fishingLogs?: Model.FishingLog[] | null;
}
export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const logs = fishingLogs || [];
const formatDateTime = (date?: Date) => {
if (!date) return "--";
const d = new Date(date);
return d.toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCoord = (lat?: number, lon?: number) => {
if (lat === undefined || lon === undefined) return "--";
return `${lat.toFixed(4)}°N, ${lon.toFixed(4)}°E`;
};
const getStatusLabel = (status?: number) => {
switch (status) {
case 0:
return { label: t("diary.tripDetail.logStatusPending"), color: "#FEF3C7", textColor: "#92400E" };
case 1:
return { label: t("diary.tripDetail.logStatusActive"), color: "#DBEAFE", textColor: "#1E40AF" };
case 2:
return { label: t("diary.tripDetail.logStatusCompleted"), color: "#D1FAE5", textColor: "#065F46" };
default:
return { label: t("diary.tripDetail.logStatusUnknown"), color: "#F3F4F6", textColor: "#4B5563" };
}
};
return (
<SectionCard
title={t("diary.tripDetail.fishingLogs")}
icon="boat-outline"
count={logs.length}
collapsible
defaultExpanded={true}
>
{logs.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="fish-outline" size={40} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noFishingLogs")}
</Text>
</View>
) : (
<View style={styles.list}>
{logs.map((log, index) => {
const status = getStatusLabel(log.status);
const catchCount = log.info?.length || 0;
return (
<View
key={log.fishing_log_id || index}
style={[styles.logItem, { borderColor: colors.separator }]}
>
{/* Header */}
<View style={styles.logHeader}>
<View style={styles.logIndex}>
<Text style={[styles.logIndexText, { color: colors.primary }]}>
#{index + 1}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.color }]}>
<Text style={[styles.statusText, { color: status.textColor }]}>
{status.label}
</Text>
</View>
</View>
{/* Time Info */}
<View style={styles.timeRow}>
<View style={styles.timeItem}>
<Ionicons name="play-circle-outline" size={16} color={colors.success || "#22C55E"} />
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}>
{t("diary.tripDetail.startTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
{formatDateTime(log.start_at)}
</Text>
</View>
<View style={styles.timeItem}>
<Ionicons name="stop-circle-outline" size={16} color={colors.error || "#EF4444"} />
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}>
{t("diary.tripDetail.endTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
{formatDateTime(log.end_at)}
</Text>
</View>
</View>
{/* Location Info */}
<View style={[styles.locationContainer, { backgroundColor: colors.backgroundSecondary }]}>
<View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.success || "#22C55E"} />
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}>
{t("diary.tripDetail.startLocation")}:
</Text>
<Text style={[styles.locationValue, { color: colors.text }]}>
{formatCoord(log.start_lat, log.start_lon)}
</Text>
</View>
<View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.error || "#EF4444"} />
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}>
{t("diary.tripDetail.haulLocation")}:
</Text>
<Text style={[styles.locationValue, { color: colors.text }]}>
{formatCoord(log.haul_lat, log.haul_lon)}
</Text>
</View>
</View>
{/* Catch Info */}
{catchCount > 0 && (
<View style={styles.catchContainer}>
<View style={styles.catchHeader}>
<Ionicons name="fish" size={16} color={colors.primary} />
<Text style={[styles.catchLabel, { color: colors.text }]}>
{t("diary.tripDetail.catchInfo")} ({catchCount} {t("diary.tripDetail.species")})
</Text>
</View>
<View style={styles.catchList}>
{log.info?.slice(0, 3).map((fish, fishIndex) => (
<View key={fishIndex} style={styles.catchItem}>
<Text style={[styles.fishName, { color: colors.text }]}>
{fish.fish_name || t("diary.tripDetail.unknownFish")}
</Text>
<Text style={[styles.fishAmount, { color: colors.textSecondary }]}>
{fish.catch_number} {fish.catch_unit}
</Text>
</View>
))}
{catchCount > 3 && (
<Text style={[styles.moreText, { color: colors.primary }]}>
+{catchCount - 3} {t("diary.tripDetail.more")}
</Text>
)}
</View>
</View>
)}
{/* Weather */}
{log.weather_description && (
<View style={styles.weatherRow}>
<Ionicons name="cloudy-outline" size={14} color={colors.textSecondary} />
<Text style={[styles.weatherText, { color: colors.textSecondary }]}>
{log.weather_description}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
</SectionCard>
);
}
const styles = StyleSheet.create({
emptyContainer: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 24,
gap: 8,
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
list: {
gap: 16,
},
logItem: {
borderWidth: 1,
borderRadius: 10,
padding: 12,
},
logHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
},
logIndex: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 4,
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
logIndexText: {
fontSize: 14,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 4,
},
statusText: {
fontSize: 12,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
timeRow: {
gap: 6,
marginBottom: 10,
},
timeItem: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
timeLabel: {
fontSize: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
timeValue: {
fontSize: 12,
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
locationContainer: {
padding: 10,
borderRadius: 6,
gap: 6,
marginBottom: 10,
},
locationItem: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
locationLabel: {
fontSize: 11,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
locationValue: {
fontSize: 11,
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
catchContainer: {
marginBottom: 10,
},
catchHeader: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginBottom: 6,
},
catchLabel: {
fontSize: 13,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
catchList: {
gap: 4,
paddingLeft: 22,
},
catchItem: {
flexDirection: "row",
justifyContent: "space-between",
},
fishName: {
fontSize: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
fishAmount: {
fontSize: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
moreText: {
fontSize: 12,
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
weatherRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
weatherText: {
fontSize: 12,
fontStyle: "italic",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,127 @@
import React from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeContext } from "@/hooks/use-theme-context";
interface SectionCardProps {
title: string;
icon: keyof typeof Ionicons.glyphMap;
children: React.ReactNode;
count?: number;
collapsible?: boolean;
defaultExpanded?: boolean;
}
export default function SectionCard({
title,
icon,
children,
count,
collapsible = false,
defaultExpanded = true,
}: SectionCardProps) {
const { colors } = useThemeContext();
const [expanded, setExpanded] = React.useState(defaultExpanded);
const themedStyles = {
container: {
backgroundColor: colors.card,
borderColor: colors.separator,
},
title: {
color: colors.text,
},
count: {
color: colors.textSecondary,
backgroundColor: colors.backgroundSecondary,
},
icon: colors.primary,
};
return (
<View style={[styles.container, themedStyles.container]}>
<TouchableOpacity
style={styles.header}
onPress={() => collapsible && setExpanded(!expanded)}
activeOpacity={collapsible ? 0.7 : 1}
disabled={!collapsible}
>
<View style={styles.headerLeft}>
<Ionicons name={icon} size={20} color={themedStyles.icon} />
<Text style={[styles.title, themedStyles.title]}>{title}</Text>
{count !== undefined && (
<View style={[styles.countBadge, themedStyles.count]}>
<Text style={[styles.countText, { color: colors.textSecondary }]}>
{count}
</Text>
</View>
)}
</View>
{collapsible && (
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
{expanded && <View style={styles.content}>{children}</View>}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: 12,
borderWidth: 1,
marginBottom: 16,
overflow: "hidden",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
title: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
countBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
minWidth: 24,
alignItems: "center",
},
countText: {
fontSize: 12,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
content: {
paddingHorizontal: 16,
paddingBottom: 16,
},
});

View File

@@ -0,0 +1,4 @@
export { default as SectionCard } from "./SectionCard";
export { default as AlertsSection } from "./AlertsSection";
export { default as FishingLogsSection } from "./FishingLogsSection";
export { default as BasicInfoSection } from "./BasicInfoSection";

View File

@@ -134,7 +134,7 @@ export default function MaterialCostList({
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.materialCostList")}
{t("trip.costTable.title")}
</Text>
{/* Cost Items List */}

View File

@@ -0,0 +1,120 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import FishingGearList from "./FishingGearList";
import MaterialCostList from "./MaterialCostList";
import TripNameInput from "./TripNameInput";
import TripDurationPicker from "./TripDurationPicker";
import PortSelector from "./PortSelector";
import BasicInfoInput from "./BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
import { FishingGear, TripCost } from "./index";
interface TripFormBodyProps {
mode: "add" | "edit" | "view";
tripData?: Model.Trip;
// Form state
selectedShipId: string;
setSelectedShipId: (id: string) => void;
tripName: string;
setTripName: (name: string) => void;
fishingGears: FishingGear[];
setFishingGears: (gears: FishingGear[]) => void;
tripCosts: TripCost[];
setTripCosts: (costs: TripCost[]) => void;
startDate: Date | null;
setStartDate: (date: Date | null) => void;
endDate: Date | null;
setEndDate: (date: Date | null) => void;
departurePortId: number;
setDeparturePortId: (id: number) => void;
arrivalPortId: number;
setArrivalPortId: (id: number) => void;
fishingGroundCodes: string;
setFishingGroundCodes: (codes: string) => void;
// Callbacks
onAutoFill?: (tripData: Model.Trip, selectedThingId: string) => void;
}
export default function TripFormBody({
mode,
selectedShipId,
setSelectedShipId,
tripName,
setTripName,
fishingGears,
setFishingGears,
tripCosts,
setTripCosts,
startDate,
setStartDate,
endDate,
setEndDate,
departurePortId,
setDeparturePortId,
arrivalPortId,
setArrivalPortId,
fishingGroundCodes,
setFishingGroundCodes,
onAutoFill,
}: TripFormBodyProps) {
const isEditMode = mode === "edit";
const isViewMode = mode === "view";
const isReadOnly = isViewMode;
return (
<View style={styles.container}>
{/* Auto Fill Section - only show in add mode */}
{!isEditMode && !isViewMode && onAutoFill && (
<AutoFillSection onAutoFill={onAutoFill} />
)}
{/* Ship Selector - disabled in edit and view mode */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
disabled={isEditMode || isViewMode}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
{/* Fishing Gear List */}
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
{/* Trip Cost List */}
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
disabled={isReadOnly}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
disabled={isReadOnly}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
disabled={isReadOnly}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
View,
Text,
@@ -15,15 +15,21 @@ import {
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import FishingGearList from "@/components/diary/TripFormModal/FishingGearList";
import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList";
import TripNameInput from "@/components/diary/TripFormModal/TripNameInput";
import TripDurationPicker from "@/components/diary/TripFormModal/TripDurationPicker";
import PortSelector from "@/components/diary/TripFormModal/PortSelector";
import BasicInfoInput from "@/components/diary/TripFormModal/BasicInfoInput";
import FishingGearList from "./FishingGearList";
import MaterialCostList from "./MaterialCostList";
import TripNameInput from "./TripNameInput";
import TripDurationPicker from "./TripDurationPicker";
import PortSelector from "./PortSelector";
import BasicInfoInput from "./BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
import { createTrip, updateTrip } from "@/controller/TripController";
import {
convertFishingGears,
convertTripCosts,
convertFishingGroundCodes,
parseFishingGroundCodes,
} from "@/utils/tripDataConverters";
// Internal component interfaces - extend from Model with local id for state management
export interface FishingGear extends Model.FishingGear {
@@ -38,32 +44,82 @@ interface TripFormModalProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
mode?: 'add' | 'edit' | 'view';
mode?: "add" | "edit";
tripData?: Model.Trip;
}
// Default form state
const DEFAULT_FORM_STATE = {
selectedShipId: "",
tripName: "",
fishingGears: [] as FishingGear[],
tripCosts: [] as TripCost[],
startDate: null as Date | null,
endDate: null as Date | null,
departurePortId: 1,
arrivalPortId: 1,
fishingGroundCodes: "",
};
export default function TripFormModal({
visible,
onClose,
onSuccess,
mode = 'add',
mode = "add",
tripData,
}: TripFormModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const isEditMode = mode === 'edit';
const isViewMode = mode === 'view';
const isReadOnly = isViewMode; // View mode is read-only
const isEditMode = mode === "edit";
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(Dimensions.get('window').height)).current;
const slideAnim = useRef(
new Animated.Value(Dimensions.get("window").height)
).current;
// Form state
const [selectedShipId, setSelectedShipId] = useState(DEFAULT_FORM_STATE.selectedShipId);
const [tripName, setTripName] = useState(DEFAULT_FORM_STATE.tripName);
const [fishingGears, setFishingGears] = useState<FishingGear[]>(DEFAULT_FORM_STATE.fishingGears);
const [tripCosts, setTripCosts] = useState<TripCost[]>(DEFAULT_FORM_STATE.tripCosts);
const [startDate, setStartDate] = useState<Date | null>(DEFAULT_FORM_STATE.startDate);
const [endDate, setEndDate] = useState<Date | null>(DEFAULT_FORM_STATE.endDate);
const [departurePortId, setDeparturePortId] = useState(DEFAULT_FORM_STATE.departurePortId);
const [arrivalPortId, setArrivalPortId] = useState(DEFAULT_FORM_STATE.arrivalPortId);
const [fishingGroundCodes, setFishingGroundCodes] = useState(DEFAULT_FORM_STATE.fishingGroundCodes);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form to default state
const resetForm = useCallback(() => {
setSelectedShipId(DEFAULT_FORM_STATE.selectedShipId);
setTripName(DEFAULT_FORM_STATE.tripName);
setFishingGears(DEFAULT_FORM_STATE.fishingGears);
setTripCosts(DEFAULT_FORM_STATE.tripCosts);
setStartDate(DEFAULT_FORM_STATE.startDate);
setEndDate(DEFAULT_FORM_STATE.endDate);
setDeparturePortId(DEFAULT_FORM_STATE.departurePortId);
setArrivalPortId(DEFAULT_FORM_STATE.arrivalPortId);
setFishingGroundCodes(DEFAULT_FORM_STATE.fishingGroundCodes);
}, []);
// Fill form with trip data
const fillFormWithTripData = useCallback((data: Model.Trip, prefix: string) => {
setSelectedShipId(data.vms_id || "");
setTripName(data.name || "");
setFishingGears(convertFishingGears(data.fishing_gears, prefix));
setTripCosts(convertTripCosts(data.trip_cost, prefix));
if (data.departure_time) setStartDate(new Date(data.departure_time));
if (data.arrival_time) setEndDate(new Date(data.arrival_time));
if (data.departure_port_id) setDeparturePortId(data.departure_port_id);
if (data.arrival_port_id) setArrivalPortId(data.arrival_port_id);
setFishingGroundCodes(convertFishingGroundCodes(data.fishing_ground_codes));
}, []);
// Handle animation when modal visibility changes
useEffect(() => {
if (visible) {
// Open animation: fade overlay + slide content up
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
@@ -79,75 +135,16 @@ export default function TripFormModal({
}
}, [visible, fadeAnim, slideAnim]);
// Form state
const [selectedShipId, setSelectedShipId] = useState<string>("");
const [tripName, setTripName] = useState("");
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [departurePortId, setDeparturePortId] = useState<number>(1);
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Pre-fill form when in edit or view mode
// Pre-fill form when in edit mode
useEffect(() => {
if ((isEditMode || isViewMode) && tripData && visible) {
// Fill ship ID (use vms_id as thingId)
setSelectedShipId(tripData.vms_id || "");
if (isEditMode && tripData && visible) {
fillFormWithTripData(tripData, "edit");
}
}, [isEditMode, tripData, visible, fillFormWithTripData]);
// Fill trip name
setTripName(tripData.name || "");
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
id: `${mode}-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
}));
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `${mode}-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill dates
if (tripData.departure_time) {
setStartDate(new Date(tripData.departure_time));
}
if (tripData.arrival_time) {
setEndDate(new Date(tripData.arrival_time));
}
// Fill ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
}
}, [isEditMode, isViewMode, tripData, visible, mode]);
const handleCancel = () => {
// Close animation: fade overlay + slide content down
// Close modal with animation
const closeWithAnimation = useCallback(
(shouldResetForm: boolean = true) => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
@@ -155,141 +152,73 @@ export default function TripFormModal({
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get('window').height,
toValue: Dimensions.get("window").height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
// Close modal after animation completes
onClose();
// Only reset form in add/edit mode, not needed for view mode
if (!isViewMode) {
// Reset form after closing
setTimeout(() => {
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
}, 100);
if (shouldResetForm) {
setTimeout(resetForm, 100);
}
});
};
},
[fadeAnim, slideAnim, onClose, resetForm]
);
// Handle close modal after successful submit - always resets form
const handleSuccessClose = () => {
// Reset animation values for next open
// Handle cancel
const handleCancel = useCallback(() => {
closeWithAnimation(true);
}, [closeWithAnimation]);
// Handle close after successful submit
const handleSuccessClose = useCallback(() => {
fadeAnim.setValue(0);
slideAnim.setValue(Dimensions.get('window').height);
// Close modal immediately
slideAnim.setValue(Dimensions.get("window").height);
onClose();
// Reset form after closing
setTimeout(() => {
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
}, 100);
};
setTimeout(resetForm, 100);
}, [fadeAnim, slideAnim, onClose, resetForm]);
// Handle auto-fill from last trip data
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
const handleAutoFill = useCallback(
(tripData: Model.Trip, selectedThingId: string) => {
setSelectedShipId(selectedThingId);
// Fill trip name
// if (tripData.name) {
// setTripName(tripData.name);
// }
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map(
(gear, index) => ({
id: `auto-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
})
setFishingGears(convertFishingGears(tripData.fishing_gears, "auto"));
setTripCosts(convertTripCosts(tripData.trip_cost, "auto"));
if (tripData.departure_port_id) setDeparturePortId(tripData.departure_port_id);
if (tripData.arrival_port_id) setArrivalPortId(tripData.arrival_port_id);
setFishingGroundCodes(convertFishingGroundCodes(tripData.fishing_ground_codes));
},
[]
);
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `auto-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill departure and arrival ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (
tripData.fishing_ground_codes &&
Array.isArray(tripData.fishing_ground_codes)
) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
};
const handleSubmit = async () => {
// Validate thingId is required
// Validate form
const validateForm = useCallback((): boolean => {
if (!selectedShipId) {
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
return;
return false;
}
// Validate dates are required
if (!startDate || !endDate) {
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return;
return false;
}
// Validate trip name is required
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return;
return false;
}
return true;
}, [selectedShipId, startDate, endDate, tripName, t]);
// Parse fishing ground codes from comma-separated string to array of numbers
const fishingGroundCodesArray = fishingGroundCodes
.split(",")
.map((code) => parseInt(code.trim()))
.filter((code) => !isNaN(code));
// Format API body
const apiBody: Model.TripAPIBody = {
// Build API body
const buildApiBody = useCallback((): Model.TripAPIBody => {
return {
thing_id: selectedShipId,
name: tripName,
departure_time: startDate ? startDate.toISOString() : "",
departure_time: startDate?.toISOString() || "",
departure_port_id: departurePortId,
arrival_time: endDate ? endDate.toISOString() : "",
arrival_time: endDate?.toISOString() || "",
arrival_port_id: arrivalPortId,
fishing_ground_codes: fishingGroundCodesArray,
fishing_ground_codes: parseFishingGroundCodes(fishingGroundCodes),
fishing_gears: fishingGears.map((gear) => ({
name: gear.name,
number: gear.number,
@@ -302,44 +231,43 @@ export default function TripFormModal({
total_cost: cost.total_cost,
})),
};
}, [
selectedShipId,
tripName,
startDate,
endDate,
departurePortId,
arrivalPortId,
fishingGroundCodes,
fishingGears,
tripCosts,
]);
// Handle form submit
const handleSubmit = useCallback(async () => {
if (!validateForm()) return;
const apiBody = buildApiBody();
setIsSubmitting(true);
try {
let response;
if (isEditMode && tripData) {
// Edit mode: call updateTrip
response = await updateTrip(tripData.id, apiBody);
await updateTrip(tripData.id, apiBody);
} else {
// Add mode: call createTrip
response = await createTrip(selectedShipId, apiBody);
await createTrip(selectedShipId, apiBody);
}
// Check if response is successful (response exists)
// Call onSuccess callback first (to refresh data)
if (onSuccess) {
onSuccess();
}
// Show success alert
onSuccess?.();
Alert.alert(
t("common.success"),
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
);
// Reset form and close modal
console.log("Calling handleSuccessClose");
handleSuccessClose();
} catch (error: any) {
console.error(isEditMode ? "Error updating trip:" : "Error creating trip:", error);
// Log detailed error information for debugging
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response data:", JSON.stringify(error.response.data, null, 2));
}
console.log("Request body was:", JSON.stringify(apiBody, null, 2));
console.error(
isEditMode ? "Error updating trip:" : "Error creating trip:",
error
);
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
@@ -347,30 +275,25 @@ export default function TripFormModal({
} finally {
setIsSubmitting(false);
}
};
}, [
validateForm,
buildApiBody,
isEditMode,
tripData,
selectedShipId,
onSuccess,
t,
handleSuccessClose,
]);
const themedStyles = {
modalContainer: {
backgroundColor: colors.card,
},
header: {
borderBottomColor: colors.separator,
},
title: {
color: colors.text,
},
footer: {
borderTopColor: colors.separator,
},
cancelButton: {
backgroundColor: colors.backgroundSecondary,
},
cancelButtonText: {
color: colors.textSecondary,
},
submitButton: {
backgroundColor: colors.primary,
},
modalContainer: { backgroundColor: colors.card },
header: { borderBottomColor: colors.separator },
title: { color: colors.text },
footer: { borderTopColor: colors.separator },
cancelButton: { backgroundColor: colors.backgroundSecondary },
cancelButtonText: { color: colors.textSecondary },
submitButton: { backgroundColor: colors.primary },
};
return (
@@ -385,7 +308,7 @@ export default function TripFormModal({
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] }
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
@@ -394,39 +317,31 @@ export default function TripFormModal({
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{isViewMode
? t("diary.viewTrip")
: isEditMode
? t("diary.editTrip")
: t("diary.addTrip")}
{isEditMode ? t("diary.editTrip") : t("diary.addTrip")}
</Text>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
contentContainerStyle={isViewMode ? { paddingBottom: 40 } : undefined}
>
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* Auto Fill Section - only show in add mode */}
{!isEditMode && !isViewMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* Ship Selector - disabled in edit and view mode */}
{/* Ship Selector - disabled in edit mode */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
disabled={isEditMode || isViewMode}
disabled={isEditMode}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
<TripNameInput value={tripName} onChange={setTripName} />
{/* Fishing Gear List */}
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
<FishingGearList items={fishingGears} onChange={setFishingGears} />
{/* Trip Cost List */}
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
<MaterialCostList items={tripCosts} onChange={setTripCosts} />
{/* Trip Duration */}
<TripDurationPicker
@@ -434,7 +349,6 @@ export default function TripFormModal({
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
disabled={isReadOnly}
/>
{/* Port Selector */}
@@ -443,28 +357,23 @@ export default function TripFormModal({
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
disabled={isReadOnly}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
disabled={isReadOnly}
/>
</ScrollView>
{/* Footer - hide in view mode */}
{!isViewMode && (
{/* Footer */}
<View style={[styles.footer, themedStyles.footer]}>
<TouchableOpacity
style={[styles.cancelButton, themedStyles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text
style={[styles.cancelButtonText, themedStyles.cancelButtonText]}
>
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
@@ -472,7 +381,7 @@ export default function TripFormModal({
style={[
styles.submitButton,
themedStyles.submitButton,
isSubmitting && styles.submitButtonDisabled
isSubmitting && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
activeOpacity={0.7}
@@ -487,7 +396,6 @@ export default function TripFormModal({
)}
</TouchableOpacity>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
@@ -505,10 +413,7 @@ const styles = StyleSheet.create({
borderTopRightRadius: 24,
maxHeight: "90%",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,

View File

@@ -65,3 +65,4 @@ export const API_MANAGER_ALARM = "/api/alarms/confirm";
export const API_GET_ALL_SHIP = "/api/sgw/ships";
export const API_GET_ALL_PORT = "/api/sgw/ports";
export const API_GET_PHOTO = "/api/sgw/photo";
export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";

View File

@@ -0,0 +1,12 @@
import { api } from "@/config";
import { API_GET_PHOTO, API_GET_TRIP_CREW } from "@/constants";
export async function queryTripCrew(tripId: string) {
return api.get<Model.TripCrews[]>(`${API_GET_TRIP_CREW}/${tripId}`);
}
export async function queryCrewImage(personal_id: string) {
return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, {
responseType: "arraybuffer",
});
}

View File

@@ -23,7 +23,8 @@
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
"theme_system": "System",
"retry": "Retry"
},
"navigation": {
"home": "Monitor",
@@ -177,7 +178,44 @@
"viewTrip": "Trip Details",
"saveChanges": "Save Changes",
"updateTripSuccess": "Trip updated successfully!",
"updateTripError": "Unable to update trip. Please try again."
"updateTripError": "Unable to update trip. Please try again.",
"crew": {
"title": "Crew Members",
"loading": "Loading crew members...",
"fetchError": "Unable to load crew members. Please try again.",
"noCrewMembers": "No crew members in this trip yet",
"totalMembers": "Total: {{count}} members",
"member": "Crew Member",
"phone": "Phone",
"personalId": "ID Number",
"joinedAt": "Joined Date",
"leftAt": "Left Date",
"note": "Note",
"deleteConfirmTitle": "Delete Crew Member",
"deleteConfirmMessage": "Are you sure you want to remove {{name}} from this trip?",
"deleteSuccess": "Crew member removed successfully",
"roles": {
"captain": "Captain",
"crew": "Crew",
"engineer": "Engineer",
"cook": "Cook"
},
"form": {
"addTitle": "Add Crew Member",
"editTitle": "Edit Crew Member",
"name": "Full Name",
"namePlaceholder": "Enter full name",
"nameRequired": "Please enter name",
"personalIdPlaceholder": "Enter ID number",
"personalIdRequired": "Please enter ID number",
"phonePlaceholder": "Enter phone number",
"role": "Role",
"address": "Address",
"addressPlaceholder": "Enter address",
"notePlaceholder": "Enter note (optional)",
"saveError": "Unable to save. Please try again."
}
}
},
"trip": {
"infoTrip": "Trip Information",

View File

@@ -23,7 +23,8 @@
"theme": "Giao diện",
"theme_light": "Sáng",
"theme_dark": "Tối",
"theme_system": "Hệ thống"
"theme_system": "Hệ thống",
"retry": "Thử lại"
},
"navigation": {
"home": "Giám sát",
@@ -177,7 +178,87 @@
"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."
"updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.",
"crew": {
"title": "Danh sách thuyền viên",
"loading": "Đang tải danh sách thuyền viên...",
"fetchError": "Không thể tải danh sách thuyền viên. Vui lòng thử lại.",
"noCrewMembers": "Chưa có thuyền viên trong chuyến đi này",
"totalMembers": "Tổng cộng: {{count}} thuyền viên",
"member": "Thuyền viên",
"phone": "Điện thoại",
"personalId": "CMND/CCCD",
"joinedAt": "Ngày tham gia",
"leftAt": "Ngày rời đi",
"note": "Ghi chú",
"deleteConfirmTitle": "Xóa thuyền viên",
"deleteConfirmMessage": "Bạn có chắc chắn muốn xóa {{name}} khỏi chuyến đi này?",
"deleteSuccess": "Đã xóa thuyền viên thành công",
"roles": {
"captain": "Thuyền trưởng",
"crew": "Thuyền viên",
"engineer": "Kỹ sư",
"cook": "Đầu bếp"
},
"form": {
"addTitle": "Thêm thuyền viên",
"editTitle": "Chỉnh sửa thuyền viên",
"name": "Họ và tên",
"namePlaceholder": "Nhập họ và tên",
"nameRequired": "Vui lòng nhập họ tên",
"personalIdPlaceholder": "Nhập số CMND/CCCD",
"personalIdRequired": "Vui lòng nhập số CMND/CCCD",
"phonePlaceholder": "Nhập số điện thoại",
"role": "Chức vụ",
"address": "Địa chỉ",
"addressPlaceholder": "Nhập địa chỉ",
"notePlaceholder": "Nhập ghi chú (nếu có)",
"saveError": "Không thể lưu thông tin. Vui lòng thử lại."
}
},
"tripDetail": {
"title": "Chi tiết chuyến đi",
"notFound": "Không tìm thấy thông tin chuyến đi",
"basicInfo": "Thông tin cơ bản",
"shipId": "Mã tàu VMS",
"departureTime": "Thời gian khởi hành",
"arrivalTime": "Thời gian về bến",
"departurePort": "Cảng khởi hành",
"arrivalPort": "Cảng cập bến",
"fishingGrounds": "Ngư trường",
"alerts": "Danh sách cảnh báo",
"noAlerts": "Không có cảnh báo",
"unknownAlert": "Cảnh báo không xác định",
"confirmed": "Đã xác nhận",
"costs": "Chi phí chuyến đi",
"noCosts": "Chưa có chi phí",
"unknownCost": "Chi phí không xác định",
"totalCost": "Tổng chi phí",
"gears": "Danh sách ngư cụ",
"noGears": "Chưa có ngư cụ",
"unknownGear": "Ngư cụ không xác định",
"quantity": "Số lượng",
"crew": "Danh sách thuyền viên",
"noCrew": "Chưa có thuyền viên",
"unknownCrew": "Thuyền viên không xác định",
"roleCaptain": "Thuyền trưởng",
"roleCrew": "Thuyền viên",
"roleEngineer": "Kỹ sư",
"fishingLogs": "Danh sách mẻ lưới",
"noFishingLogs": "Chưa có mẻ lưới",
"startTime": "Bắt đầu",
"endTime": "Kết thúc",
"startLocation": "Vị trí thả",
"haulLocation": "Vị trí kéo",
"catchInfo": "Sản lượng",
"species": "loài",
"unknownFish": "Cá không xác định",
"more": "loài khác",
"logStatusPending": "Chờ xử lý",
"logStatusActive": "Đang thực hiện",
"logStatusCompleted": "Hoàn thành",
"logStatusUnknown": "Không xác định"
}
},
"trip": {
"infoTrip": "Thông Tin Chuyến Đi",

View File

@@ -0,0 +1,54 @@
import { FishingGear, TripCost } from "@/components/diary/TripFormModal";
/**
* Convert Model.FishingGear[] to component format with id
*/
export function convertFishingGears(
gears: Model.FishingGear[] | undefined,
prefix: string = "gear"
): FishingGear[] {
if (!gears || !Array.isArray(gears)) return [];
return gears.map((gear, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
}));
}
/**
* Convert Model.TripCost[] to component format with id
*/
export function convertTripCosts(
costs: Model.TripCost[] | undefined,
prefix: string = "cost"
): TripCost[] {
if (!costs || !Array.isArray(costs)) return [];
return costs.map((cost, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
}
/**
* Convert fishing ground codes array to comma-separated string
*/
export function convertFishingGroundCodes(codes: number[] | undefined): string {
if (!codes || !Array.isArray(codes)) return "";
return codes.join(", ");
}
/**
* Parse comma-separated string to number array
*/
export function parseFishingGroundCodes(codesString: string): number[] {
return codesString
.split(",")
.map((code) => parseInt(code.trim()))
.filter((code) => !isNaN(code));
}