Files
sgw-owner-app/app/(tabs)/diary.tsx

600 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard";
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
import TripFormModal from "@/components/diary/TripFormModal";
import { useThings } from "@/state/use-thing";
import { useTripsList } from "@/state/use-tripslist";
import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
export default function diary() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
const [showFilterModal, setShowFilterModal] = useState(false);
const [showTripFormModal, setShowTripFormModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
const [filters, setFilters] = useState<FilterValues>({
status: null,
startDate: null,
endDate: null,
selectedShip: null, // Tàu được chọn
});
// State for pagination
const [allTrips, setAllTrips] = useState<any[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const isInitialLoad = useRef(true);
const flatListRef = useRef<FlatList>(null);
// Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = {
offset: 0,
limit: 200,
order: "name",
dir: "asc",
metadata: {
not_empty: "ship_name, ship_reg_number",
},
};
// Gọi API things nếu chưa có dữ liệu
const { things, getThings } = useThings();
useEffect(() => {
if (!things) {
getThings(payloadThings);
}
}, [things, getThings]);
// Gọi API ships nếu chưa có dữ liệu
const { ships, getShip } = useShip();
useEffect(() => {
if (!ships) {
getShip();
}
}, [ships, getShip]);
// State cho payload trips
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
name: "",
order: "",
dir: "desc",
offset: 0,
limit: 10,
metadata: {
from: "",
to: "",
ship_name: "",
reg_number: "",
province_code: "",
owner_id: "",
ship_id: "",
status: "",
},
});
const { tripsList, getTripsList, loading } = useTripsList();
// Gọi API trips lần đầu
useEffect(() => {
if (!isInitialLoad.current) return;
isInitialLoad.current = false;
setAllTrips([]);
setHasMore(true);
getTripsList(payloadTrips);
}, []);
// Xử lý khi nhận được dữ liệu mới từ API
useEffect(() => {
if (tripsList?.trips && tripsList.trips.length > 0) {
if (isInitialLoad.current || payloadTrips.offset === 0) {
// Load lần đầu hoặc reset do filter
setAllTrips(tripsList.trips);
isInitialLoad.current = false;
} else {
// Load more - append data
setAllTrips((prevTrips) => {
// Tránh duplicate bằng cách check ID
const existingIds = new Set(prevTrips.map((trip) => trip.id));
const newTrips = tripsList.trips!.filter(
(trip) => !existingIds.has(trip.id)
);
return [...prevTrips, ...newTrips];
});
}
// Check if có thêm data không
const totalFetched = payloadTrips.offset + tripsList.trips.length;
setHasMore(totalFetched < (tripsList.total || 0));
setIsLoadingMore(false);
} else if (tripsList && payloadTrips.offset === 0) {
// Trường hợp API trả về rỗng khi filter
setAllTrips([]);
setHasMore(false);
setIsLoadingMore(false);
}
}, [tripsList]);
const handleFilter = () => {
setShowFilterModal(true);
};
const handleApplyFilters = (newFilters: FilterValues) => {
setFilters(newFilters);
// Cập nhật payload với filter mới và reset offset
// Lưu ý: status gửi lên server là string
const updatedPayload: Model.TripListBody = {
...payloadTrips,
offset: 0, // Reset về đầu khi filter
metadata: {
...payloadTrips.metadata,
from: newFilters.startDate
? dayjs(newFilters.startDate).startOf("day").toISOString()
: "",
to: newFilters.endDate
? dayjs(newFilters.endDate).endOf("day").toISOString()
: "",
// Convert number status sang string để gửi lên server
status: newFilters.status !== null ? String(newFilters.status) : "",
// Thêm ship_id từ tàu đã chọn
ship_name: newFilters.selectedShip?.shipName || "",
},
};
// Reset trips khi apply filter mới
setAllTrips([]);
setHasMore(true);
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
setShowFilterModal(false);
};
// Hàm load more data khi scroll đến cuối
const handleLoadMore = useCallback(() => {
// 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;
}
setIsLoadingMore(true);
const newOffset = payloadTrips.offset + payloadTrips.limit;
const updatedPayload = {
...payloadTrips,
offset: newOffset,
};
setPayloadTrips(updatedPayload);
getTripsList(updatedPayload);
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
const handleViewTrip = (tripId: string) => {
// Navigate to trip detail page - chỉ truyền tripId
router.push({
pathname: "/trip-detail",
params: { tripId },
});
};
const handleEditTrip = (tripId: string) => {
// Find the trip from allTrips
const tripToEdit = allTrips.find((trip) => trip.id === tripId);
if (tripToEdit) {
setEditingTrip(tripToEdit);
setShowTripFormModal(true);
}
};
const handleViewTeam = (tripId: string) => {
const trip = allTrips.find((t) => t.id === tripId);
if (trip) {
router.push({
pathname: "/trip-crew",
params: {
tripId: trip.id,
tripName: trip.name || "",
tripStatus: String(trip.trip_status ?? ""), // trip_status là số
},
});
}
};
const handleSendTrip = useCallback(
async (tripId: string) => {
try {
// Import dynamically để tránh circular dependency
const { tripApproveRequest } = await import(
"@/controller/TripController"
);
// Gọi API gửi yêu cầu phê duyệt
await tripApproveRequest(tripId);
console.log("✅ Send trip for approval:", tripId);
// Reload danh sách để cập nhật trạng thái
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 100);
} catch (error) {
console.error("❌ Error sending trip for approval:", error);
}
},
[payloadTrips, getTripsList]
);
const handleDeleteTrip = useCallback(
(tripId: string) => {
Alert.alert(
t("diary.cancelTripConfirmTitle") || "Xác nhận hủy chuyến đi",
t("diary.cancelTripConfirmMessage") ||
"Bạn có chắc chắn muốn hủy chuyến đi này?",
[
{
text: t("common.cancel") || "Hủy",
style: "cancel",
},
{
text: t("common.confirm") || "Xác nhận",
style: "destructive",
onPress: async () => {
try {
// Import dynamically để tránh circular dependency
const { tripCancelRequest } = await import(
"@/controller/TripController"
);
// Gọi API hủy chuyến đi
await tripCancelRequest(tripId);
console.log("✅ Trip cancelled:", tripId);
// Reload danh sách để cập nhật trạng thái
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
}, 100);
} catch (error) {
console.error("❌ Error cancelling trip:", error);
Alert.alert(
t("common.error") || "Lỗi",
t("diary.cancelTripError") ||
"Không thể hủy chuyến đi. Vui lòng thử lại."
);
}
},
},
]
);
},
[payloadTrips, getTripsList, t]
);
// Handle sau khi thêm chuyến đi thành công
const handleTripAddSuccess = useCallback(() => {
// Reset về trang đầu và gọi lại API
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll FlatList lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 100);
}, [payloadTrips, getTripsList]);
// Handle refresh - pull-to-refresh
const handleRefresh = useCallback(() => {
setRefreshing(true);
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload).finally(() => {
setRefreshing(false);
});
}, [payloadTrips, getTripsList]);
// Dynamic styles based on theme
const themedStyles = {
safeArea: {
backgroundColor: colors.background,
},
titleText: {
color: colors.text,
},
countText: {
color: colors.textSecondary,
},
addButton: {
backgroundColor: colors.primary,
},
emptyText: {
color: colors.textSecondary,
},
};
// Render mỗi item trong FlatList
const renderTripItem = useCallback(
({ item }: { item: any }) => (
<TripCard
trip={item}
onView={() => handleViewTrip(item.id)}
onEdit={() => handleEditTrip(item.id)}
onTeam={() => handleViewTeam(item.id)}
onSend={() => handleSendTrip(item.id)}
onDelete={() => handleDeleteTrip(item.id)}
/>
),
[
handleViewTrip,
handleEditTrip,
handleViewTeam,
handleSendTrip,
handleDeleteTrip,
]
);
// Key extractor cho FlatList
const keyExtractor = useCallback((item: any) => item.id, []);
// Loading indicator khi load more
const renderFooter = () => {
// Không hiển thị loading footer nếu không có dữ liệu hoặc không đang load more
if (!isLoadingMore || allTrips.length === 0) return null;
return (
<View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.loadingMore")}
</Text>
</View>
);
};
// Empty component
const renderEmpty = () => {
// Hiển thị loading khi đang load (lần đầu hoặc load more) và chưa có dữ liệu
if (loading && allTrips.length === 0) {
return (
<View style={styles.emptyState}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// Chỉ hiển thị "không có dữ liệu" khi đã load xong và thực sự không có trips
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noTripsFound")}
</Text>
</View>
);
};
return (
<SafeAreaView
style={[styles.safeArea, themedStyles.safeArea]}
edges={["top"]}
>
<View style={styles.container}>
{/* Header */}
<View style={styles.headerRow}>
<Text style={[styles.titleText, themedStyles.titleText]}>
{t("diary.title")}
</Text>
</View>
{/* Filter & Add Button Row */}
<View style={styles.actionRow}>
<FilterButton
onPress={handleFilter}
isFiltered={
filters.status !== null ||
filters.startDate !== null ||
filters.endDate !== null ||
filters.selectedShip !== null
}
/>
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={() => setShowTripFormModal(true)}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color="#FFFFFF" />
<Text style={styles.addButtonText}>{t("diary.addTrip")}</Text>
</TouchableOpacity>
</View>
{/* Trip Count */}
<Text style={[styles.countText, themedStyles.countText]}>
{t("diary.tripListCount", { count: tripsList?.total || 0 })}
</Text>
{/* Trip List with FlatList */}
<FlatList
ref={flatListRef}
data={allTrips}
renderItem={renderTripItem}
keyExtractor={keyExtractor}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={handleRefresh}
ListFooterComponent={renderFooter}
ListEmptyComponent={renderEmpty}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
/>
</View>
{/* Filter Modal */}
<FilterModal
visible={showFilterModal}
onClose={() => setShowFilterModal(false)}
onApply={handleApplyFilters}
/>
{/* Add/Edit Trip Modal */}
<TripFormModal
visible={showTripFormModal}
onClose={() => {
setShowTripFormModal(false);
setEditingTrip(null);
}}
onSuccess={handleTripAddSuccess}
mode={editingTrip ? "edit" : "add"}
tripData={editingTrip || undefined}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
container: {
flex: 1,
padding: 10,
},
titleText: {
fontSize: 28,
fontWeight: "700",
lineHeight: 36,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
},
actionRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
marginBottom: 12,
},
countText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
marginBottom: 10,
},
addButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
height: 40,
borderRadius: 8,
gap: 6,
},
addButtonText: {
fontSize: 14,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
scrollContent: {
paddingBottom: 20,
},
emptyState: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 60,
},
emptyText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
loadingFooter: {
paddingVertical: 20,
alignItems: "center",
justifyContent: "center",
flexDirection: "row",
gap: 10,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});