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:
@@ -2,15 +2,14 @@ import { Tabs, useSegments } from "expo-router";
|
|||||||
|
|
||||||
import { HapticTab } from "@/components/haptic-tab";
|
import { HapticTab } from "@/components/haptic-tab";
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import { Colors } from "@/constants/theme";
|
|
||||||
import { queryProfile } from "@/controller/AuthController";
|
import { queryProfile } from "@/controller/AuthController";
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
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 { addUserStorage } from "@/utils/storage";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
const { colors, colorScheme } = useThemeContext();
|
||||||
const segments = useSegments() as string[];
|
const segments = useSegments() as string[];
|
||||||
const prev = useRef<string | null>(null);
|
const prev = useRef<string | null>(null);
|
||||||
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||||
@@ -51,9 +50,18 @@ export default function TabLayout() {
|
|||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
tabBarActiveTintColor: colors.tint,
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarButton: HapticTab,
|
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
|
<Tabs.Screen
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import FilterButton from "@/components/diary/FilterButton";
|
import FilterButton from "@/components/diary/FilterButton";
|
||||||
import TripCard from "@/components/diary/TripCard";
|
import TripCard from "@/components/diary/TripCard";
|
||||||
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
|
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
|
||||||
@@ -23,10 +24,10 @@ import { useThemeContext } from "@/hooks/use-theme-context";
|
|||||||
export default function diary() {
|
export default function diary() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { colors } = useThemeContext();
|
const { colors } = useThemeContext();
|
||||||
|
const router = useRouter();
|
||||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||||
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
|
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
|
||||||
const [viewingTrip, setViewingTrip] = useState<Model.Trip | null>(null);
|
|
||||||
const [filters, setFilters] = useState<FilterValues>({
|
const [filters, setFilters] = useState<FilterValues>({
|
||||||
status: null,
|
status: null,
|
||||||
startDate: null,
|
startDate: null,
|
||||||
@@ -41,6 +42,10 @@ export default function diary() {
|
|||||||
const isInitialLoad = useRef(true);
|
const isInitialLoad = useRef(true);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
|
// Refs to prevent duplicate API calls from React Compiler/Strict Mode
|
||||||
|
const hasInitializedThings = useRef(false);
|
||||||
|
const hasInitializedTrips = useRef(false);
|
||||||
|
|
||||||
// Body call API things (đang fix cứng)
|
// Body call API things (đang fix cứng)
|
||||||
const payloadThings: Model.SearchThingBody = {
|
const payloadThings: Model.SearchThingBody = {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -55,6 +60,8 @@ export default function diary() {
|
|||||||
// Gọi API things
|
// Gọi API things
|
||||||
const { getThings } = useThings();
|
const { getThings } = useThings();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasInitializedThings.current) return;
|
||||||
|
hasInitializedThings.current = true;
|
||||||
getThings(payloadThings);
|
getThings(payloadThings);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -79,10 +86,10 @@ export default function diary() {
|
|||||||
|
|
||||||
const { tripsList, getTripsList, loading } = useTripsList();
|
const { tripsList, getTripsList, loading } = useTripsList();
|
||||||
|
|
||||||
// console.log("Payload trips:", payloadTrips);
|
|
||||||
|
|
||||||
// Gọi API trips lần đầu
|
// Gọi API trips lần đầu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasInitializedTrips.current) return;
|
||||||
|
hasInitializedTrips.current = true;
|
||||||
isInitialLoad.current = true;
|
isInitialLoad.current = true;
|
||||||
setAllTrips([]);
|
setAllTrips([]);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
@@ -157,7 +164,11 @@ export default function diary() {
|
|||||||
|
|
||||||
// Hàm load more data khi scroll đến cuối
|
// Hàm load more data khi scroll đến cuối
|
||||||
const handleLoadMore = useCallback(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +180,7 @@ export default function diary() {
|
|||||||
};
|
};
|
||||||
setPayloadTrips(updatedPayload);
|
setPayloadTrips(updatedPayload);
|
||||||
getTripsList(updatedPayload);
|
getTripsList(updatedPayload);
|
||||||
}, [isLoadingMore, hasMore, payloadTrips]);
|
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
|
||||||
|
|
||||||
// const handleTripPress = (tripId: string) => {
|
// const handleTripPress = (tripId: string) => {
|
||||||
// // TODO: Navigate to trip detail
|
// // TODO: Navigate to trip detail
|
||||||
@@ -177,11 +188,16 @@ export default function diary() {
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
const handleViewTrip = (tripId: string) => {
|
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);
|
const tripToView = allTrips.find((trip) => trip.id === tripId);
|
||||||
if (tripToView) {
|
if (tripToView) {
|
||||||
setViewingTrip(tripToView);
|
router.push({
|
||||||
setShowAddTripModal(true);
|
pathname: "/trip-detail",
|
||||||
|
params: {
|
||||||
|
tripId: tripToView.id,
|
||||||
|
tripData: JSON.stringify(tripToView)
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,8 +211,13 @@ export default function diary() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleViewTeam = (tripId: string) => {
|
const handleViewTeam = (tripId: string) => {
|
||||||
console.log("View team:", tripId);
|
const trip = allTrips.find((t) => t.id === tripId);
|
||||||
// TODO: Navigate to team management
|
if (trip) {
|
||||||
|
router.push({
|
||||||
|
pathname: "/trip-crew",
|
||||||
|
params: { tripId: trip.id, tripName: trip.name || "" },
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendTrip = (tripId: string) => {
|
const handleSendTrip = (tripId: string) => {
|
||||||
@@ -260,7 +281,13 @@ export default function diary() {
|
|||||||
onDelete={() => handleDeleteTrip(item.id)}
|
onDelete={() => handleDeleteTrip(item.id)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[handleViewTrip, handleEditTrip, handleViewTeam, handleSendTrip, handleDeleteTrip]
|
[
|
||||||
|
handleViewTrip,
|
||||||
|
handleEditTrip,
|
||||||
|
handleViewTeam,
|
||||||
|
handleSendTrip,
|
||||||
|
handleDeleteTrip,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Key extractor cho FlatList
|
// Key extractor cho FlatList
|
||||||
@@ -301,10 +328,15 @@ export default function diary() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
|
<SafeAreaView
|
||||||
|
style={[styles.safeArea, themedStyles.safeArea]}
|
||||||
|
edges={["top"]}
|
||||||
|
>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* 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 */}
|
{/* Filter & Add Button Row */}
|
||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
@@ -358,17 +390,16 @@ export default function diary() {
|
|||||||
onApply={handleApplyFilters}
|
onApply={handleApplyFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add/Edit/View Trip Modal */}
|
{/* Add/Edit Trip Modal */}
|
||||||
<AddTripModal
|
<AddTripModal
|
||||||
visible={showAddTripModal}
|
visible={showAddTripModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowAddTripModal(false);
|
setShowAddTripModal(false);
|
||||||
setEditingTrip(null);
|
setEditingTrip(null);
|
||||||
setViewingTrip(null);
|
|
||||||
}}
|
}}
|
||||||
onSuccess={handleTripAddSuccess}
|
onSuccess={handleTripAddSuccess}
|
||||||
mode={viewingTrip ? 'view' : editingTrip ? 'edit' : 'add'}
|
mode={editingTrip ? "edit" : "add"}
|
||||||
tripData={viewingTrip || editingTrip || undefined}
|
tripData={editingTrip || undefined}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -394,17 +425,10 @@ const styles = StyleSheet.create({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
actionRow: {
|
actionRow: {
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: 20,
|
gap: 12,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
countText: {
|
countText: {
|
||||||
@@ -421,7 +445,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
height: 40,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import {
|
|||||||
DarkTheme,
|
DarkTheme,
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
|
Theme,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
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 "react-native-reanimated";
|
||||||
// import Toast from "react-native-toast-message";
|
|
||||||
|
|
||||||
// import { toastConfig } from "@/config";
|
|
||||||
import { toastConfig } from "@/config";
|
import { toastConfig } from "@/config";
|
||||||
import { setRouterInstance } from "@/config/auth";
|
import { setRouterInstance } from "@/config/auth";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
@@ -18,21 +18,49 @@ import {
|
|||||||
ThemeProvider as AppThemeProvider,
|
ThemeProvider as AppThemeProvider,
|
||||||
useThemeContext,
|
useThemeContext,
|
||||||
} from "@/hooks/use-theme-context";
|
} from "@/hooks/use-theme-context";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import "../global.css";
|
import "../global.css";
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { colorScheme } = useThemeContext();
|
const { colorScheme, colors } = useThemeContext();
|
||||||
console.log("Color Scheme: ", colorScheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRouterInstance(router);
|
setRouterInstance(router);
|
||||||
}, [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 (
|
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
|
<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"
|
initialRouteName="auth/login"
|
||||||
>
|
>
|
||||||
<Stack.Screen
|
<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
|
<Stack.Screen
|
||||||
name="modal"
|
name="modal"
|
||||||
options={{ presentation: "formSheet", title: "Modal" }}
|
options={{ presentation: "formSheet", title: "Modal" }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||||
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
|||||||
283
app/trip-crew.tsx
Normal file
283
app/trip-crew.tsx
Normal 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
312
app/trip-detail.tsx
Normal 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" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
444
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal file
444
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
285
components/diary/TripCrewModal/CrewCard.tsx
Normal file
285
components/diary/TripCrewModal/CrewCard.tsx
Normal 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
69
components/diary/TripCrewModal/CrewList.tsx
Normal file
69
components/diary/TripCrewModal/CrewList.tsx
Normal 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
377
components/diary/TripCrewModal/index.tsx
Normal file
377
components/diary/TripCrewModal/index.tsx
Normal 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" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
156
components/diary/TripDetailSections/AlertsSection.tsx
Normal file
156
components/diary/TripDetailSections/AlertsSection.tsx
Normal 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
135
components/diary/TripDetailSections/BasicInfoSection.tsx
Normal file
135
components/diary/TripDetailSections/BasicInfoSection.tsx
Normal 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" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
356
components/diary/TripDetailSections/FishingLogsSection.tsx
Normal file
356
components/diary/TripDetailSections/FishingLogsSection.tsx
Normal 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
127
components/diary/TripDetailSections/SectionCard.tsx
Normal file
127
components/diary/TripDetailSections/SectionCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
title: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
children: React.ReactNode;
|
||||||
|
count?: number;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
count,
|
||||||
|
collapsible = false,
|
||||||
|
defaultExpanded = true,
|
||||||
|
}: SectionCardProps) {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||||
|
|
||||||
|
const themedStyles = {
|
||||||
|
container: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderColor: colors.separator,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
color: colors.textSecondary,
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
},
|
||||||
|
icon: colors.primary,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, themedStyles.container]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.header}
|
||||||
|
onPress={() => collapsible && setExpanded(!expanded)}
|
||||||
|
activeOpacity={collapsible ? 0.7 : 1}
|
||||||
|
disabled={!collapsible}
|
||||||
|
>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<Ionicons name={icon} size={20} color={themedStyles.icon} />
|
||||||
|
<Text style={[styles.title, themedStyles.title]}>{title}</Text>
|
||||||
|
{count !== undefined && (
|
||||||
|
<View style={[styles.countBadge, themedStyles.count]}>
|
||||||
|
<Text style={[styles.countText, { color: colors.textSecondary }]}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{collapsible && (
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? "chevron-up" : "chevron-down"}
|
||||||
|
size={20}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{expanded && <View style={styles.content}>{children}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
marginBottom: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
countBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
4
components/diary/TripDetailSections/index.ts
Normal file
4
components/diary/TripDetailSections/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as SectionCard } from "./SectionCard";
|
||||||
|
export { default as AlertsSection } from "./AlertsSection";
|
||||||
|
export { default as FishingLogsSection } from "./FishingLogsSection";
|
||||||
|
export { default as BasicInfoSection } from "./BasicInfoSection";
|
||||||
@@ -134,7 +134,7 @@ export default function MaterialCostList({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
|
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
|
||||||
{t("diary.materialCostList")}
|
{t("trip.costTable.title")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Cost Items List */}
|
{/* Cost Items List */}
|
||||||
|
|||||||
120
components/diary/TripFormModal/TripFormBody.tsx
Normal file
120
components/diary/TripFormModal/TripFormBody.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import FishingGearList from "./FishingGearList";
|
||||||
|
import MaterialCostList from "./MaterialCostList";
|
||||||
|
import TripNameInput from "./TripNameInput";
|
||||||
|
import TripDurationPicker from "./TripDurationPicker";
|
||||||
|
import PortSelector from "./PortSelector";
|
||||||
|
import BasicInfoInput from "./BasicInfoInput";
|
||||||
|
import ShipSelector from "./ShipSelector";
|
||||||
|
import AutoFillSection from "./AutoFillSection";
|
||||||
|
import { FishingGear, TripCost } from "./index";
|
||||||
|
|
||||||
|
interface TripFormBodyProps {
|
||||||
|
mode: "add" | "edit" | "view";
|
||||||
|
tripData?: Model.Trip;
|
||||||
|
// Form state
|
||||||
|
selectedShipId: string;
|
||||||
|
setSelectedShipId: (id: string) => void;
|
||||||
|
tripName: string;
|
||||||
|
setTripName: (name: string) => void;
|
||||||
|
fishingGears: FishingGear[];
|
||||||
|
setFishingGears: (gears: FishingGear[]) => void;
|
||||||
|
tripCosts: TripCost[];
|
||||||
|
setTripCosts: (costs: TripCost[]) => void;
|
||||||
|
startDate: Date | null;
|
||||||
|
setStartDate: (date: Date | null) => void;
|
||||||
|
endDate: Date | null;
|
||||||
|
setEndDate: (date: Date | null) => void;
|
||||||
|
departurePortId: number;
|
||||||
|
setDeparturePortId: (id: number) => void;
|
||||||
|
arrivalPortId: number;
|
||||||
|
setArrivalPortId: (id: number) => void;
|
||||||
|
fishingGroundCodes: string;
|
||||||
|
setFishingGroundCodes: (codes: string) => void;
|
||||||
|
// Callbacks
|
||||||
|
onAutoFill?: (tripData: Model.Trip, selectedThingId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TripFormBody({
|
||||||
|
mode,
|
||||||
|
selectedShipId,
|
||||||
|
setSelectedShipId,
|
||||||
|
tripName,
|
||||||
|
setTripName,
|
||||||
|
fishingGears,
|
||||||
|
setFishingGears,
|
||||||
|
tripCosts,
|
||||||
|
setTripCosts,
|
||||||
|
startDate,
|
||||||
|
setStartDate,
|
||||||
|
endDate,
|
||||||
|
setEndDate,
|
||||||
|
departurePortId,
|
||||||
|
setDeparturePortId,
|
||||||
|
arrivalPortId,
|
||||||
|
setArrivalPortId,
|
||||||
|
fishingGroundCodes,
|
||||||
|
setFishingGroundCodes,
|
||||||
|
onAutoFill,
|
||||||
|
}: TripFormBodyProps) {
|
||||||
|
const isEditMode = mode === "edit";
|
||||||
|
const isViewMode = mode === "view";
|
||||||
|
const isReadOnly = isViewMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Auto Fill Section - only show in add mode */}
|
||||||
|
{!isEditMode && !isViewMode && onAutoFill && (
|
||||||
|
<AutoFillSection onAutoFill={onAutoFill} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ship Selector - disabled in edit and view mode */}
|
||||||
|
<ShipSelector
|
||||||
|
selectedShipId={selectedShipId}
|
||||||
|
onChange={setSelectedShipId}
|
||||||
|
disabled={isEditMode || isViewMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Trip Name */}
|
||||||
|
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
|
||||||
|
|
||||||
|
{/* Fishing Gear List */}
|
||||||
|
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
|
||||||
|
|
||||||
|
{/* Trip Cost List */}
|
||||||
|
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
|
||||||
|
|
||||||
|
{/* Trip Duration */}
|
||||||
|
<TripDurationPicker
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Port Selector */}
|
||||||
|
<PortSelector
|
||||||
|
departurePortId={departurePortId}
|
||||||
|
arrivalPortId={arrivalPortId}
|
||||||
|
onDeparturePortChange={setDeparturePortId}
|
||||||
|
onArrivalPortChange={setArrivalPortId}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fishing Ground Codes */}
|
||||||
|
<BasicInfoInput
|
||||||
|
fishingGroundCodes={fishingGroundCodes}
|
||||||
|
onChange={setFishingGroundCodes}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,15 +15,21 @@ import {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import FishingGearList from "@/components/diary/TripFormModal/FishingGearList";
|
import FishingGearList from "./FishingGearList";
|
||||||
import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList";
|
import MaterialCostList from "./MaterialCostList";
|
||||||
import TripNameInput from "@/components/diary/TripFormModal/TripNameInput";
|
import TripNameInput from "./TripNameInput";
|
||||||
import TripDurationPicker from "@/components/diary/TripFormModal/TripDurationPicker";
|
import TripDurationPicker from "./TripDurationPicker";
|
||||||
import PortSelector from "@/components/diary/TripFormModal/PortSelector";
|
import PortSelector from "./PortSelector";
|
||||||
import BasicInfoInput from "@/components/diary/TripFormModal/BasicInfoInput";
|
import BasicInfoInput from "./BasicInfoInput";
|
||||||
import ShipSelector from "./ShipSelector";
|
import ShipSelector from "./ShipSelector";
|
||||||
import AutoFillSection from "./AutoFillSection";
|
import AutoFillSection from "./AutoFillSection";
|
||||||
import { createTrip, updateTrip } from "@/controller/TripController";
|
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
|
// Internal component interfaces - extend from Model with local id for state management
|
||||||
export interface FishingGear extends Model.FishingGear {
|
export interface FishingGear extends Model.FishingGear {
|
||||||
@@ -38,32 +44,82 @@ interface TripFormModalProps {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
mode?: 'add' | 'edit' | 'view';
|
mode?: "add" | "edit";
|
||||||
tripData?: Model.Trip;
|
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({
|
export default function TripFormModal({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
mode = 'add',
|
mode = "add",
|
||||||
tripData,
|
tripData,
|
||||||
}: TripFormModalProps) {
|
}: TripFormModalProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { colors } = useThemeContext();
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
const isEditMode = mode === 'edit';
|
const isEditMode = mode === "edit";
|
||||||
const isViewMode = mode === 'view';
|
|
||||||
const isReadOnly = isViewMode; // View mode is read-only
|
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
const slideAnim = useRef(new Animated.Value(Dimensions.get('window').height)).current;
|
const slideAnim = useRef(
|
||||||
|
new Animated.Value(Dimensions.get("window").height)
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// 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
|
// Handle animation when modal visibility changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
// Open animation: fade overlay + slide content up
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(fadeAnim, {
|
Animated.timing(fadeAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
@@ -79,75 +135,16 @@ export default function TripFormModal({
|
|||||||
}
|
}
|
||||||
}, [visible, fadeAnim, slideAnim]);
|
}, [visible, fadeAnim, slideAnim]);
|
||||||
|
|
||||||
// Form state
|
// Pre-fill form when in edit mode
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((isEditMode || isViewMode) && tripData && visible) {
|
if (isEditMode && tripData && visible) {
|
||||||
// Fill ship ID (use vms_id as thingId)
|
fillFormWithTripData(tripData, "edit");
|
||||||
setSelectedShipId(tripData.vms_id || "");
|
}
|
||||||
|
}, [isEditMode, tripData, visible, fillFormWithTripData]);
|
||||||
|
|
||||||
// Fill trip name
|
// Close modal with animation
|
||||||
setTripName(tripData.name || "");
|
const closeWithAnimation = useCallback(
|
||||||
|
(shouldResetForm: boolean = true) => {
|
||||||
// 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
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(fadeAnim, {
|
Animated.timing(fadeAnim, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -155,141 +152,73 @@ export default function TripFormModal({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: Dimensions.get('window').height,
|
toValue: Dimensions.get("window").height,
|
||||||
duration: 250,
|
duration: 250,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]).start(() => {
|
]).start(() => {
|
||||||
// Close modal after animation completes
|
|
||||||
onClose();
|
onClose();
|
||||||
|
if (shouldResetForm) {
|
||||||
// Only reset form in add/edit mode, not needed for view mode
|
setTimeout(resetForm, 100);
|
||||||
if (!isViewMode) {
|
|
||||||
// Reset form after closing
|
|
||||||
setTimeout(() => {
|
|
||||||
setSelectedShipId("");
|
|
||||||
setTripName("");
|
|
||||||
setFishingGears([]);
|
|
||||||
setTripCosts([]);
|
|
||||||
setStartDate(null);
|
|
||||||
setEndDate(null);
|
|
||||||
setDeparturePortId(1);
|
|
||||||
setArrivalPortId(1);
|
|
||||||
setFishingGroundCodes("");
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[fadeAnim, slideAnim, onClose, resetForm]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle close modal after successful submit - always resets form
|
// Handle cancel
|
||||||
const handleSuccessClose = () => {
|
const handleCancel = useCallback(() => {
|
||||||
// Reset animation values for next open
|
closeWithAnimation(true);
|
||||||
|
}, [closeWithAnimation]);
|
||||||
|
|
||||||
|
// Handle close after successful submit
|
||||||
|
const handleSuccessClose = useCallback(() => {
|
||||||
fadeAnim.setValue(0);
|
fadeAnim.setValue(0);
|
||||||
slideAnim.setValue(Dimensions.get('window').height);
|
slideAnim.setValue(Dimensions.get("window").height);
|
||||||
|
|
||||||
// Close modal immediately
|
|
||||||
onClose();
|
onClose();
|
||||||
|
setTimeout(resetForm, 100);
|
||||||
// Reset form after closing
|
}, [fadeAnim, slideAnim, onClose, resetForm]);
|
||||||
setTimeout(() => {
|
|
||||||
setSelectedShipId("");
|
|
||||||
setTripName("");
|
|
||||||
setFishingGears([]);
|
|
||||||
setTripCosts([]);
|
|
||||||
setStartDate(null);
|
|
||||||
setEndDate(null);
|
|
||||||
setDeparturePortId(1);
|
|
||||||
setArrivalPortId(1);
|
|
||||||
setFishingGroundCodes("");
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle auto-fill from last trip data
|
// Handle auto-fill from last trip data
|
||||||
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
|
const handleAutoFill = useCallback(
|
||||||
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
|
(tripData: Model.Trip, selectedThingId: string) => {
|
||||||
setSelectedShipId(selectedThingId);
|
setSelectedShipId(selectedThingId);
|
||||||
|
setFishingGears(convertFishingGears(tripData.fishing_gears, "auto"));
|
||||||
// Fill trip name
|
setTripCosts(convertTripCosts(tripData.trip_cost, "auto"));
|
||||||
// if (tripData.name) {
|
if (tripData.departure_port_id) setDeparturePortId(tripData.departure_port_id);
|
||||||
// setTripName(tripData.name);
|
if (tripData.arrival_port_id) setArrivalPortId(tripData.arrival_port_id);
|
||||||
// }
|
setFishingGroundCodes(convertFishingGroundCodes(tripData.fishing_ground_codes));
|
||||||
|
},
|
||||||
// Fill fishing gears
|
[]
|
||||||
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
|
|
||||||
const gears: FishingGear[] = tripData.fishing_gears.map(
|
|
||||||
(gear, index) => ({
|
|
||||||
id: `auto-${Date.now()}-${index}`,
|
|
||||||
name: gear.name || "",
|
|
||||||
number: gear.number?.toString() || "",
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
setFishingGears(gears);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill trip costs
|
// Validate form
|
||||||
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
|
const validateForm = useCallback((): boolean => {
|
||||||
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
|
|
||||||
if (!selectedShipId) {
|
if (!selectedShipId) {
|
||||||
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
|
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate dates are required
|
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
|
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate trip name is required
|
|
||||||
if (!tripName.trim()) {
|
if (!tripName.trim()) {
|
||||||
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
|
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}, [selectedShipId, startDate, endDate, tripName, t]);
|
||||||
|
|
||||||
// Parse fishing ground codes from comma-separated string to array of numbers
|
// Build API body
|
||||||
const fishingGroundCodesArray = fishingGroundCodes
|
const buildApiBody = useCallback((): Model.TripAPIBody => {
|
||||||
.split(",")
|
return {
|
||||||
.map((code) => parseInt(code.trim()))
|
|
||||||
.filter((code) => !isNaN(code));
|
|
||||||
|
|
||||||
// Format API body
|
|
||||||
const apiBody: Model.TripAPIBody = {
|
|
||||||
thing_id: selectedShipId,
|
thing_id: selectedShipId,
|
||||||
name: tripName,
|
name: tripName,
|
||||||
departure_time: startDate ? startDate.toISOString() : "",
|
departure_time: startDate?.toISOString() || "",
|
||||||
departure_port_id: departurePortId,
|
departure_port_id: departurePortId,
|
||||||
arrival_time: endDate ? endDate.toISOString() : "",
|
arrival_time: endDate?.toISOString() || "",
|
||||||
arrival_port_id: arrivalPortId,
|
arrival_port_id: arrivalPortId,
|
||||||
fishing_ground_codes: fishingGroundCodesArray,
|
fishing_ground_codes: parseFishingGroundCodes(fishingGroundCodes),
|
||||||
fishing_gears: fishingGears.map((gear) => ({
|
fishing_gears: fishingGears.map((gear) => ({
|
||||||
name: gear.name,
|
name: gear.name,
|
||||||
number: gear.number,
|
number: gear.number,
|
||||||
@@ -302,44 +231,43 @@ export default function TripFormModal({
|
|||||||
total_cost: cost.total_cost,
|
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);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
|
|
||||||
if (isEditMode && tripData) {
|
if (isEditMode && tripData) {
|
||||||
// Edit mode: call updateTrip
|
await updateTrip(tripData.id, apiBody);
|
||||||
response = await updateTrip(tripData.id, apiBody);
|
|
||||||
} else {
|
} else {
|
||||||
// Add mode: call createTrip
|
await createTrip(selectedShipId, apiBody);
|
||||||
response = await createTrip(selectedShipId, apiBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if response is successful (response exists)
|
onSuccess?.();
|
||||||
|
|
||||||
// Call onSuccess callback first (to refresh data)
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show success alert
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("common.success"),
|
t("common.success"),
|
||||||
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
|
isEditMode ? t("diary.updateTripSuccess") : t("diary.createTripSuccess")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset form and close modal
|
|
||||||
console.log("Calling handleSuccessClose");
|
|
||||||
handleSuccessClose();
|
handleSuccessClose();
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(isEditMode ? "Error updating trip:" : "Error creating trip:", error);
|
console.error(
|
||||||
// Log detailed error information for debugging
|
isEditMode ? "Error updating trip:" : "Error creating trip:",
|
||||||
if (error.response) {
|
error
|
||||||
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));
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("common.error"),
|
t("common.error"),
|
||||||
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
|
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
|
||||||
@@ -347,30 +275,25 @@ export default function TripFormModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
}, [
|
||||||
|
validateForm,
|
||||||
|
buildApiBody,
|
||||||
|
isEditMode,
|
||||||
|
tripData,
|
||||||
|
selectedShipId,
|
||||||
|
onSuccess,
|
||||||
|
t,
|
||||||
|
handleSuccessClose,
|
||||||
|
]);
|
||||||
|
|
||||||
const themedStyles = {
|
const themedStyles = {
|
||||||
modalContainer: {
|
modalContainer: { backgroundColor: colors.card },
|
||||||
backgroundColor: colors.card,
|
header: { borderBottomColor: colors.separator },
|
||||||
},
|
title: { color: colors.text },
|
||||||
header: {
|
footer: { borderTopColor: colors.separator },
|
||||||
borderBottomColor: colors.separator,
|
cancelButton: { backgroundColor: colors.backgroundSecondary },
|
||||||
},
|
cancelButtonText: { color: colors.textSecondary },
|
||||||
title: {
|
submitButton: { backgroundColor: colors.primary },
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
borderTopColor: colors.separator,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
backgroundColor: colors.backgroundSecondary,
|
|
||||||
},
|
|
||||||
cancelButtonText: {
|
|
||||||
color: colors.textSecondary,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -385,7 +308,7 @@ export default function TripFormModal({
|
|||||||
style={[
|
style={[
|
||||||
styles.modalContainer,
|
styles.modalContainer,
|
||||||
themedStyles.modalContainer,
|
themedStyles.modalContainer,
|
||||||
{ transform: [{ translateY: slideAnim }] }
|
{ transform: [{ translateY: slideAnim }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -394,39 +317,31 @@ export default function TripFormModal({
|
|||||||
<Ionicons name="close" size={24} color={colors.text} />
|
<Ionicons name="close" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.title, themedStyles.title]}>
|
<Text style={[styles.title, themedStyles.title]}>
|
||||||
{isViewMode
|
{isEditMode ? t("diary.editTrip") : t("diary.addTrip")}
|
||||||
? t("diary.viewTrip")
|
|
||||||
: isEditMode
|
|
||||||
? t("diary.editTrip")
|
|
||||||
: t("diary.addTrip")}
|
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.placeholder} />
|
<View style={styles.placeholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
style={styles.content}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
contentContainerStyle={isViewMode ? { paddingBottom: 40 } : undefined}
|
|
||||||
>
|
|
||||||
{/* Auto Fill Section - only show in add mode */}
|
{/* 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
|
<ShipSelector
|
||||||
selectedShipId={selectedShipId}
|
selectedShipId={selectedShipId}
|
||||||
onChange={setSelectedShipId}
|
onChange={setSelectedShipId}
|
||||||
disabled={isEditMode || isViewMode}
|
disabled={isEditMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Trip Name */}
|
{/* Trip Name */}
|
||||||
<TripNameInput value={tripName} onChange={setTripName} disabled={isReadOnly} />
|
<TripNameInput value={tripName} onChange={setTripName} />
|
||||||
|
|
||||||
{/* Fishing Gear List */}
|
{/* Fishing Gear List */}
|
||||||
<FishingGearList items={fishingGears} onChange={setFishingGears} disabled={isReadOnly} />
|
<FishingGearList items={fishingGears} onChange={setFishingGears} />
|
||||||
|
|
||||||
{/* Trip Cost List */}
|
{/* Trip Cost List */}
|
||||||
<MaterialCostList items={tripCosts} onChange={setTripCosts} disabled={isReadOnly} />
|
<MaterialCostList items={tripCosts} onChange={setTripCosts} />
|
||||||
|
|
||||||
{/* Trip Duration */}
|
{/* Trip Duration */}
|
||||||
<TripDurationPicker
|
<TripDurationPicker
|
||||||
@@ -434,7 +349,6 @@ export default function TripFormModal({
|
|||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Port Selector */}
|
{/* Port Selector */}
|
||||||
@@ -443,28 +357,23 @@ export default function TripFormModal({
|
|||||||
arrivalPortId={arrivalPortId}
|
arrivalPortId={arrivalPortId}
|
||||||
onDeparturePortChange={setDeparturePortId}
|
onDeparturePortChange={setDeparturePortId}
|
||||||
onArrivalPortChange={setArrivalPortId}
|
onArrivalPortChange={setArrivalPortId}
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Fishing Ground Codes */}
|
{/* Fishing Ground Codes */}
|
||||||
<BasicInfoInput
|
<BasicInfoInput
|
||||||
fishingGroundCodes={fishingGroundCodes}
|
fishingGroundCodes={fishingGroundCodes}
|
||||||
onChange={setFishingGroundCodes}
|
onChange={setFishingGroundCodes}
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Footer - hide in view mode */}
|
{/* Footer */}
|
||||||
{!isViewMode && (
|
|
||||||
<View style={[styles.footer, themedStyles.footer]}>
|
<View style={[styles.footer, themedStyles.footer]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, themedStyles.cancelButton]}
|
style={[styles.cancelButton, themedStyles.cancelButton]}
|
||||||
onPress={handleCancel}
|
onPress={handleCancel}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text
|
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
|
||||||
style={[styles.cancelButtonText, themedStyles.cancelButtonText]}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -472,7 +381,7 @@ export default function TripFormModal({
|
|||||||
style={[
|
style={[
|
||||||
styles.submitButton,
|
styles.submitButton,
|
||||||
themedStyles.submitButton,
|
themedStyles.submitButton,
|
||||||
isSubmitting && styles.submitButtonDisabled
|
isSubmitting && styles.submitButtonDisabled,
|
||||||
]}
|
]}
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
@@ -487,7 +396,6 @@ export default function TripFormModal({
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -505,10 +413,7 @@ const styles = StyleSheet.create({
|
|||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
maxHeight: "90%",
|
maxHeight: "90%",
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: {
|
shadowOffset: { width: 0, height: -4 },
|
||||||
width: 0,
|
|
||||||
height: -4,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
|
|||||||
@@ -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_SHIP = "/api/sgw/ships";
|
||||||
export const API_GET_ALL_PORT = "/api/sgw/ports";
|
export const API_GET_ALL_PORT = "/api/sgw/ports";
|
||||||
export const API_GET_PHOTO = "/api/sgw/photo";
|
export const API_GET_PHOTO = "/api/sgw/photo";
|
||||||
|
export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";
|
||||||
|
|||||||
12
controller/TripCrewController.ts
Normal file
12
controller/TripCrewController.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"theme_light": "Light",
|
"theme_light": "Light",
|
||||||
"theme_dark": "Dark",
|
"theme_dark": "Dark",
|
||||||
"theme_system": "System"
|
"theme_system": "System",
|
||||||
|
"retry": "Retry"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Monitor",
|
"home": "Monitor",
|
||||||
@@ -177,7 +178,44 @@
|
|||||||
"viewTrip": "Trip Details",
|
"viewTrip": "Trip Details",
|
||||||
"saveChanges": "Save Changes",
|
"saveChanges": "Save Changes",
|
||||||
"updateTripSuccess": "Trip updated successfully!",
|
"updateTripSuccess": "Trip updated successfully!",
|
||||||
"updateTripError": "Unable to update trip. Please try again."
|
"updateTripError": "Unable to update trip. Please try again.",
|
||||||
|
"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": {
|
"trip": {
|
||||||
"infoTrip": "Trip Information",
|
"infoTrip": "Trip Information",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"theme": "Giao diện",
|
"theme": "Giao diện",
|
||||||
"theme_light": "Sáng",
|
"theme_light": "Sáng",
|
||||||
"theme_dark": "Tối",
|
"theme_dark": "Tối",
|
||||||
"theme_system": "Hệ thống"
|
"theme_system": "Hệ thống",
|
||||||
|
"retry": "Thử lại"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Giám sát",
|
"home": "Giám sát",
|
||||||
@@ -177,7 +178,87 @@
|
|||||||
"viewTrip": "Chi tiết chuyến đi",
|
"viewTrip": "Chi tiết chuyến đi",
|
||||||
"saveChanges": "Lưu thay đổi",
|
"saveChanges": "Lưu thay đổi",
|
||||||
"updateTripSuccess": "Cập nhật chuyến đi thành công!",
|
"updateTripSuccess": "Cập nhật chuyến đi thành công!",
|
||||||
"updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại."
|
"updateTripError": "Không thể cập nhật chuyến đi. Vui lòng thử lại.",
|
||||||
|
"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": {
|
"trip": {
|
||||||
"infoTrip": "Thông Tin Chuyến Đi",
|
"infoTrip": "Thông Tin Chuyến Đi",
|
||||||
|
|||||||
54
utils/tripDataConverters.ts
Normal file
54
utils/tripDataConverters.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user