621 lines
17 KiB
TypeScript
621 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 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 reload - gọi lại API
|
|
const handleReload = useCallback(() => {
|
|
isInitialLoad.current = true;
|
|
setAllTrips([]);
|
|
setHasMore(true);
|
|
const resetPayload: Model.TripListBody = {
|
|
...payloadTrips,
|
|
offset: 0,
|
|
};
|
|
setPayloadTrips(resetPayload);
|
|
getTripsList(resetPayload);
|
|
|
|
// Scroll FlatList lên đầu
|
|
setTimeout(() => {
|
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
|
}, 100);
|
|
}, [payloadTrips, getTripsList]);
|
|
|
|
// Dynamic styles based on theme
|
|
const themedStyles = {
|
|
safeArea: {
|
|
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>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.reloadButton,
|
|
{ backgroundColor: colors.backgroundSecondary },
|
|
]}
|
|
onPress={handleReload}
|
|
activeOpacity={0.7}
|
|
disabled={loading}
|
|
>
|
|
{loading && allTrips.length === 0 ? (
|
|
<ActivityIndicator size="small" color={colors.primary} />
|
|
) : (
|
|
<Ionicons name="reload" size={20} color={colors.primary} />
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Filter & Add Button Row */}
|
|
<View style={styles.actionRow}>
|
|
<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}
|
|
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,
|
|
},
|
|
reloadButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
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",
|
|
}),
|
|
},
|
|
});
|