From e405a0bcfa82bad58ecedea5d586573f1364cefb Mon Sep 17 00:00:00 2001 From: MinhNN Date: Sun, 7 Dec 2025 20:23:10 +0700 Subject: [PATCH] =?UTF-8?q?update=20tab=20nh=E1=BA=ADt=20k=C3=BD=20(L?= =?UTF-8?q?=E1=BB=8Dc,=20Ng=C3=B4n=20ng=E1=BB=AF,...)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 8 +- app/(tabs)/diary.tsx | 154 +++++++------ components/diary/DateRangePicker.tsx | 20 +- components/diary/FilterButton.tsx | 27 ++- components/diary/FilterModal.tsx | 74 +++++-- components/diary/SearchBar.tsx | 68 ------ components/diary/ShipDropdown.tsx | 319 +++++++++++++++++++++++++++ components/diary/StatusDropdown.tsx | 30 +-- components/diary/TripCard.tsx | 123 +++++++---- components/diary/mockData.ts | 92 -------- components/diary/types.ts | 83 +++++-- constants/index.ts | 1 + controller/TripController.ts | 7 +- controller/typings.d.ts | 30 +++ locales/en.json | 58 +++++ locales/vi.json | 58 +++++ state/use-tripslist.ts | 31 +++ 17 files changed, 851 insertions(+), 332 deletions(-) delete mode 100644 components/diary/SearchBar.tsx create mode 100644 components/diary/ShipDropdown.tsx delete mode 100644 components/diary/mockData.ts create mode 100644 state/use-tripslist.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 829e496..e08aa65 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -12,7 +12,7 @@ export default function TabLayout() { const segments = useSegments() as string[]; const prev = useRef(null); const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null; - const { t, locale } = useI18n(); + const { t } = useI18n(); useEffect(() => { if (prev.current !== currentSegment) { // console.log("Tab changed ->", { from: prev.current, to: currentSegment }); @@ -60,7 +60,11 @@ export default function TabLayout() { options={{ title: t("navigation.manager"), tabBarIcon: ({ color }) => ( - + ), }} /> diff --git a/app/(tabs)/diary.tsx b/app/(tabs)/diary.tsx index e635ac8..6de5b7d 100644 --- a/app/(tabs)/diary.tsx +++ b/app/(tabs)/diary.tsx @@ -9,22 +9,25 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; -import SearchBar from "@/components/diary/SearchBar"; import FilterButton from "@/components/diary/FilterButton"; import TripCard from "@/components/diary/TripCard"; import FilterModal, { FilterValues } from "@/components/diary/FilterModal"; -import { MOCK_TRIPS } from "@/components/diary/mockData"; import { useThings } from "@/state/use-thing"; +import { useTripsList } from "@/state/use-tripslist"; +import dayjs from "dayjs"; +import { useI18n } from "@/hooks/use-i18n"; export default function diary() { - const [searchText, setSearchText] = useState(""); + const { t } = useI18n(); const [showFilterModal, setShowFilterModal] = useState(false); const [filters, setFilters] = useState({ status: null, startDate: null, endDate: null, + selectedShip: null, // Tàu được chọn }); - // Body things (đang fix cứng) + + // Body call API things (đang fix cứng) const payloadThings: Model.SearchThingBody = { offset: 0, limit: 200, @@ -34,56 +37,44 @@ export default function diary() { not_empty: "ship_name, ship_reg_number", }, }; + // Gọi API things - const { things, getThings } = useThings(); + const { getThings } = useThings(); useEffect(() => { getThings(payloadThings); }, []); - console.log(things); - - // Filter trips based on search text and filters - const filteredTrips = MOCK_TRIPS.filter((trip) => { - // Search filter - if (searchText) { - const searchLower = searchText.toLowerCase(); - const matchesSearch = - trip.title.toLowerCase().includes(searchLower) || - trip.code.toLowerCase().includes(searchLower) || - trip.vessel.toLowerCase().includes(searchLower) || - trip.vesselCode.toLowerCase().includes(searchLower); - - if (!matchesSearch) return false; - } - - // Status filter - if (filters.status && trip.status !== filters.status) { - return false; - } - - // Date range filter - if (filters.startDate || filters.endDate) { - const tripDate = new Date(trip.departureDate); - - if (filters.startDate && tripDate < filters.startDate) { - return false; - } - - if (filters.endDate) { - const endOfDay = new Date(filters.endDate); - endOfDay.setHours(23, 59, 59, 999); - if (tripDate > endOfDay) { - return false; - } - } - } - - return true; + // State cho payload trips + const [payloadTrips, setPayloadTrips] = useState({ + name: "", + order: "", + dir: "desc", + offset: 0, + limit: 10, + metadata: { + from: "", + to: "", + ship_name: "", + reg_number: "", + province_code: "", + owner_id: "", + ship_id: "", + status: "", + }, }); - const handleSearch = (text: string) => { - setSearchText(text); - }; + const { tripsList, getTripsList } = useTripsList(); + + // Gọi API trips lần đầu + useEffect(() => { + getTripsList(payloadTrips); + }, []); + + // Gọi lại API khi payload thay đổi (do filter) + useEffect(() => { + getTripsList(payloadTrips); + console.log("Payload trips:", payloadTrips); + }, [payloadTrips]); const handleFilter = () => { setShowFilterModal(true); @@ -91,6 +82,28 @@ export default function diary() { const handleApplyFilters = (newFilters: FilterValues) => { setFilters(newFilters); + + // Cập nhật payload với filter mới + // Lưu ý: status gửi lên server là string + const updatedPayload: Model.TripListBody = { + ...payloadTrips, + 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 || "", + }, + }; + + setPayloadTrips(updatedPayload); + setShowFilterModal(false); }; const handleTripPress = (tripId: string) => { @@ -124,39 +137,44 @@ export default function diary() { }; return ( - + {/* Header */} - Nhật ký chuyến đi + {t("diary.title")} - {/* Search Bar */} - - - {/* Filter Button */} - - - {/* Trip Count & Add Button */} - - - Danh sách chuyến đi ({filteredTrips.length}) - + {/* Filter & Add Button Row */} + + console.log("Add trip")} activeOpacity={0.7} > - Thêm chuyến đi + {t("diary.addTrip")} + {/* Trip Count */} + + {t("diary.tripListCount", { count: tripsList?.total || 0 })} + + {/* Trip List */} - {filteredTrips.map((trip) => ( + {tripsList?.trips?.map((trip) => ( ))} - {filteredTrips.length === 0 && ( + {(!tripsList || !tripsList.trips || tripsList.trips.length === 0) && ( - Không tìm thấy chuyến đi phù hợp + {t("diary.noTripsFound")} )} @@ -210,6 +228,13 @@ const styles = StyleSheet.create({ default: "System", }), }, + actionRow: { + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "center", + gap: 12, + marginBottom: 12, + }, headerRow: { flexDirection: "row", justifyContent: "space-between", @@ -226,6 +251,7 @@ const styles = StyleSheet.create({ android: "Roboto", default: "System", }), + marginBottom: 10, }, addButton: { flexDirection: "row", diff --git a/components/diary/DateRangePicker.tsx b/components/diary/DateRangePicker.tsx index 8337c14..185a849 100644 --- a/components/diary/DateRangePicker.tsx +++ b/components/diary/DateRangePicker.tsx @@ -9,6 +9,7 @@ import { } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import DateTimePicker from "@react-native-community/datetimepicker"; +import { useI18n } from "@/hooks/use-i18n"; interface DateRangePickerProps { startDate: Date | null; @@ -23,6 +24,7 @@ export default function DateRangePicker({ onStartDateChange, onEndDateChange, }: DateRangePickerProps) { + const { t } = useI18n(); const [showStartPicker, setShowStartPicker] = useState(false); const [showEndPicker, setShowEndPicker] = useState(false); @@ -50,7 +52,7 @@ export default function DateRangePicker({ return ( - Ngày đi + {t("diary.dateRangePicker.label")} {/* Start Date */} - {startDate ? formatDate(startDate) : "Ngày bắt đầu"} + {startDate ? formatDate(startDate) : t("diary.dateRangePicker.startDate")} @@ -77,7 +79,7 @@ export default function DateRangePicker({ activeOpacity={0.7} > - {endDate ? formatDate(endDate) : "Ngày kết thúc"} + {endDate ? formatDate(endDate) : t("diary.dateRangePicker.endDate")} @@ -96,11 +98,11 @@ export default function DateRangePicker({ setShowStartPicker(false)}> - Hủy + {t("common.cancel")} - Chọn ngày bắt đầu + {t("diary.dateRangePicker.selectStartDate")} setShowStartPicker(false)}> - Xong + {t("diary.dateRangePicker.done")} setShowEndPicker(false)}> - Hủy + {t("common.cancel")} - Chọn ngày kết thúc + {t("diary.dateRangePicker.selectEndDate")} setShowEndPicker(false)}> - Xong + {t("diary.dateRangePicker.done")} void; + isFiltered?: boolean; } -export default function FilterButton({ onPress }: FilterButtonProps) { +export default function FilterButton({ + onPress, + isFiltered, +}: FilterButtonProps) { + const { t } = useI18n(); + return ( - - Bộ lọc + + + {t("diary.filter")} + + {isFiltered && ( + + )} ); } diff --git a/components/diary/FilterModal.tsx b/components/diary/FilterModal.tsx index f75cd5d..a1ac3cc 100644 --- a/components/diary/FilterModal.tsx +++ b/components/diary/FilterModal.tsx @@ -11,7 +11,33 @@ import { import { Ionicons } from "@expo/vector-icons"; import StatusDropdown from "./StatusDropdown"; import DateRangePicker from "./DateRangePicker"; +import ShipDropdown from "./ShipDropdown"; import { TripStatus } from "./types"; +import { useI18n } from "@/hooks/use-i18n"; + +// Map status number to string - now uses i18n +export function useMapStatusNumberToString() { + const { t } = useI18n(); + + return (status: TripStatus | null): string => { + switch (status) { + case 0: + return t("diary.tripStatus.created"); + case 1: + return t("diary.tripStatus.pending"); + case 2: + return t("diary.tripStatus.approved"); + case 3: + return t("diary.tripStatus.departed"); + case 4: + return t("diary.tripStatus.completed"); + case 5: + return t("diary.tripStatus.cancelled"); + default: + return "-"; + } + }; +} interface FilterModalProps { visible: boolean; @@ -19,10 +45,16 @@ interface FilterModalProps { onApply: (filters: FilterValues) => void; } +export interface ShipOption { + id: string; + shipName: string; +} + export interface FilterValues { - status: TripStatus | null; + status: TripStatus | null; // number (0-5) hoặc null startDate: Date | null; endDate: Date | null; + selectedShip: ShipOption | null; // Tàu được chọn } export default function FilterModal({ @@ -30,22 +62,30 @@ export default function FilterModal({ onClose, onApply, }: FilterModalProps) { + const { t } = useI18n(); + const mapStatusNumberToString = useMapStatusNumberToString(); const [status, setStatus] = useState(null); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); + const [selectedShip, setSelectedShip] = useState(null); const handleReset = () => { setStatus(null); setStartDate(null); setEndDate(null); + setSelectedShip(null); }; const handleApply = () => { - onApply({ status, startDate, endDate }); + onApply({ status, startDate, endDate, selectedShip }); onClose(); }; - const hasFilters = status !== null || startDate !== null || endDate !== null; + const hasFilters = + status !== null || + startDate !== null || + endDate !== null || + selectedShip !== null; return ( - - e.stopPropagation()} @@ -69,7 +109,7 @@ export default function FilterModal({ - Bộ lọc + {t("diary.filter")} @@ -85,29 +125,37 @@ export default function FilterModal({ onStartDateChange={setStartDate} onEndDateChange={setEndDate} /> + {/* Filter Results Preview */} {hasFilters && ( - Bộ lọc đã chọn: - {status && ( + {t("diary.selectedFilters")} + {status !== null && ( - Trạng thái: {status} + {t("diary.statusLabel")} {mapStatusNumberToString(status)} )} {startDate && ( - Từ: {startDate.toLocaleDateString("vi-VN")} + {t("diary.fromLabel")} {startDate.toLocaleDateString("vi-VN")} )} {endDate && ( - Đến: {endDate.toLocaleDateString("vi-VN")} + {t("diary.toLabel")} {endDate.toLocaleDateString("vi-VN")} + + + )} + {selectedShip && ( + + + {t("diary.shipLabel")} {selectedShip.shipName} )} @@ -122,14 +170,14 @@ export default function FilterModal({ onPress={handleReset} activeOpacity={0.7} > - Đặt lại + {t("diary.reset")} - Áp dụng + {t("diary.apply")} diff --git a/components/diary/SearchBar.tsx b/components/diary/SearchBar.tsx deleted file mode 100644 index 08d9fa2..0000000 --- a/components/diary/SearchBar.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useState } from "react"; -import { View, TextInput, StyleSheet, Platform, StyleProp, ViewStyle } from "react-native"; -import { Ionicons } from "@expo/vector-icons"; - -interface SearchBarProps { - onSearch?: (text: string) => void; - style?: StyleProp; -} - -export default function SearchBar({ onSearch, style }: SearchBarProps) { - const [searchText, setSearchText] = useState(""); - - const handleChangeText = (text: string) => { - setSearchText(text); - onSearch?.(text); - }; - - return ( - - - - {searchText.length > 0 && ( - handleChangeText("")} - /> - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "#F9FAFB", - borderRadius: 12, - paddingHorizontal: 16, - paddingVertical: 12, - borderWidth: 1, - borderColor: "#E5E7EB", - }, - icon: { - marginRight: 8, - }, - input: { - flex: 1, - fontSize: 16, - color: "#111827", - fontFamily: Platform.select({ - ios: "System", - android: "Roboto", - default: "System", - }), - }, - clearIcon: { - marginLeft: 8, - }, -}); diff --git a/components/diary/ShipDropdown.tsx b/components/diary/ShipDropdown.tsx new file mode 100644 index 0000000..dc462db --- /dev/null +++ b/components/diary/ShipDropdown.tsx @@ -0,0 +1,319 @@ +import React, { useState } from "react"; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Modal, + Platform, + ScrollView, + TextInput, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThings } from "@/state/use-thing"; +import { useI18n } from "@/hooks/use-i18n"; + +interface ShipOption { + id: string; + shipName: string; +} + +interface ShipDropdownProps { + value: ShipOption | null; + onChange: (value: ShipOption | null) => void; +} + +export default function ShipDropdown({ value, onChange }: ShipDropdownProps) { + const { t } = useI18n(); + const [isOpen, setIsOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + + const { things } = useThings(); + + // Convert things to ship options, filter out items without id + const shipOptions: ShipOption[] = + things + ?.filter((thing) => thing.id != null) + .map((thing) => ({ + id: thing.id as string, + shipName: thing.metadata?.ship_name || "", + })) || []; + + // Filter ships based on search text + const filteredShips = shipOptions.filter((ship) => { + const searchLower = searchText.toLowerCase(); + return ship.shipName.toLowerCase().includes(searchLower); + }); + + const handleSelect = (ship: ShipOption | null) => { + onChange(ship); + setIsOpen(false); + setSearchText(""); + }; + + const displayValue = value ? value.shipName : t("diary.shipDropdown.placeholder"); + + return ( + + {t("diary.shipDropdown.label")} + setIsOpen(true)} + activeOpacity={0.7} + > + + {displayValue} + + + + + setIsOpen(false)} + > + setIsOpen(false)} + > + true} + > + {/* Search Input */} + + + + {searchText.length > 0 && ( + setSearchText("")}> + + + )} + + + + {/* Option to clear selection */} + handleSelect(null)} + > + + {t("diary.shipDropdown.allShips")} + + {!value && ( + + )} + + + {filteredShips.map((ship) => ( + handleSelect(ship)} + > + + + {ship.shipName} + + + {value?.id === ship.id && ( + + )} + + ))} + + {filteredShips.length === 0 && searchText.length > 0 && ( + + + {t("diary.shipDropdown.noShipsFound")} + + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 20, + }, + label: { + fontSize: 16, + fontWeight: "600", + color: "#111827", + marginBottom: 8, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + selector: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#FFFFFF", + borderWidth: 1, + borderColor: "#D1D5DB", + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + selectorText: { + fontSize: 16, + color: "#111827", + flex: 1, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + placeholder: { + color: "#9CA3AF", + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + }, + modalContent: { + backgroundColor: "#FFFFFF", + borderRadius: 12, + width: "85%", + maxHeight: "70%", + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + searchContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + backgroundColor: "#F9FAFB", + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 16, + color: "#111827", + padding: 0, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + optionsList: { + maxHeight: 350, + }, + option: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + }, + selectedOption: { + backgroundColor: "#EFF6FF", + }, + optionText: { + fontSize: 16, + color: "#111827", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + placeholderOption: { + fontStyle: "italic", + color: "#6B7280", + }, + selectedOptionText: { + color: "#3B82F6", + fontWeight: "600", + }, + shipInfo: { + flex: 1, + }, + shipName: { + fontSize: 16, + color: "#111827", + fontWeight: "500", + marginBottom: 2, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + regNumber: { + fontSize: 14, + color: "#6B7280", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + emptyState: { + paddingVertical: 24, + alignItems: "center", + }, + emptyText: { + fontSize: 14, + color: "#9CA3AF", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, +}); diff --git a/components/diary/StatusDropdown.tsx b/components/diary/StatusDropdown.tsx index 55c1bdd..23d332d 100644 --- a/components/diary/StatusDropdown.tsx +++ b/components/diary/StatusDropdown.tsx @@ -9,31 +9,33 @@ import { ScrollView, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { TripStatus, TRIP_STATUS_CONFIG } from "./types"; +import { TripStatus } from "./types"; +import { useI18n } from "@/hooks/use-i18n"; interface StatusDropdownProps { value: TripStatus | null; - onChange: (status: TripStatus | null) => void; + onChange: (value: TripStatus | null) => void; } -const STATUS_OPTIONS: Array<{ value: TripStatus | null; label: string }> = [ - { value: null, label: "Vui lòng chọn" }, - { value: "created", label: "Đã khởi tạo" }, - { value: "pending", label: "Chờ duyệt" }, - { value: "approved", label: "Đã duyệt" }, - { value: "in-progress", label: "Đang hoạt động" }, - { value: "completed", label: "Hoàn thành" }, - { value: "cancelled", label: "Đã hủy" }, -]; - export default function StatusDropdown({ value, onChange, }: StatusDropdownProps) { + const { t } = useI18n(); const [isOpen, setIsOpen] = useState(false); + const STATUS_OPTIONS: Array<{ value: TripStatus | null; label: string }> = [ + { value: null, label: t("diary.statusDropdown.placeholder") }, + { value: 0, label: t("diary.statusDropdown.created") }, + { value: 1, label: t("diary.statusDropdown.pending") }, + { value: 2, label: t("diary.statusDropdown.approved") }, + { value: 3, label: t("diary.statusDropdown.active") }, + { value: 4, label: t("diary.statusDropdown.completed") }, + { value: 5, label: t("diary.statusDropdown.cancelled") }, + ]; + const selectedLabel = - STATUS_OPTIONS.find((opt) => opt.value === value)?.label || "Vui lòng chọn"; + STATUS_OPTIONS.find((opt) => opt.value === value)?.label || t("diary.statusDropdown.placeholder"); const handleSelect = (status: TripStatus | null) => { onChange(status); @@ -42,7 +44,7 @@ export default function StatusDropdown({ return ( - Trạng thái + {t("diary.statusDropdown.label")} setIsOpen(true)} diff --git a/components/diary/TripCard.tsx b/components/diary/TripCard.tsx index 6df3c5f..139c1c3 100644 --- a/components/diary/TripCard.tsx +++ b/components/diary/TripCard.tsx @@ -7,10 +7,13 @@ import { Platform, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { Trip, TRIP_STATUS_CONFIG } from "./types"; +import { useTripStatusConfig } from "./types"; +import { useThings } from "@/state/use-thing"; +import dayjs from "dayjs"; +import { useI18n } from "@/hooks/use-i18n"; interface TripCardProps { - trip: Trip; + trip: Model.Trip; onPress?: () => void; onView?: () => void; onEdit?: () => void; @@ -19,13 +22,37 @@ interface TripCardProps { onDelete?: () => void; } -export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend, onDelete }: TripCardProps) { - const statusConfig = TRIP_STATUS_CONFIG[trip.status]; +export default function TripCard({ + trip, + onPress, + onView, + onEdit, + onTeam, + onSend, + onDelete, +}: TripCardProps) { + const { t } = useI18n(); + const { things } = useThings(); + const TRIP_STATUS_CONFIG = useTripStatusConfig(); + + // Tìm thing có id trùng với vms_id của trip + const thingOfTrip: Model.Thing | undefined = things?.find( + (thing) => thing.id === trip.vms_id + ); + + // Lấy config status từ trip_status (number) + const statusKey = trip.trip_status as keyof typeof TRIP_STATUS_CONFIG; + const statusConfig = TRIP_STATUS_CONFIG[statusKey] || { + label: "-", + bgColor: "#eee", + textColor: "#333", + icon: "help", + }; // Determine which actions to show based on status - const showEdit = trip.status === 'created' || trip.status === 'pending'; - const showSend = trip.status === 'created'; - const showDelete = trip.status === 'pending'; + const showEdit = trip.trip_status === 0 || trip.trip_status === 1; + const showSend = trip.trip_status === 0; + const showDelete = trip.trip_status === 1; return ( @@ -39,8 +66,7 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend color={statusConfig.textColor} /> - {trip.title} - {trip.code} + {trip.name} - Tàu + {t("diary.tripCard.shipCode")} - {trip.vessel} ({trip.vesselCode}) + {thingOfTrip?.metadata?.ship_reg_number /* hoặc trip.ship_id */} - Khởi hành - {trip.departureDate} + {t("diary.tripCard.departure")} + + {trip.departure_time + ? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm") + : "-"} + - Trở về - {trip.returnDate || "-"} - - - - Thời gian - {trip.duration} + {t("diary.tripCard.return")} + {/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */} + + {trip.arrival_time + ? dayjs(trip.arrival_time).format("DD/MM/YYYY HH:mm") + : "-"} + @@ -93,34 +123,54 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend {/* Action Buttons */} - + - View + {t("diary.tripCard.view")} {showEdit && ( - + - Edit + {t("diary.tripCard.edit")} )} - + - Team + {t("diary.tripCard.team")} {showSend && ( - + - Send + {t("diary.tripCard.send")} )} {showDelete && ( - + - Delete + {t("diary.tripCard.delete")} )} @@ -171,15 +221,7 @@ const styles = StyleSheet.create({ default: "System", }), }, - code: { - fontSize: 14, - color: "#6B7280", - fontFamily: Platform.select({ - ios: "System", - android: "Roboto", - default: "System", - }), - }, + badge: { paddingHorizontal: 12, paddingVertical: 4, @@ -222,9 +264,6 @@ const styles = StyleSheet.create({ default: "System", }), }, - duration: { - color: "#3B82F6", - }, divider: { height: 1, backgroundColor: "#F3F4F6", diff --git a/components/diary/mockData.ts b/components/diary/mockData.ts deleted file mode 100644 index 9a2092f..0000000 --- a/components/diary/mockData.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Trip } from "./types"; - -export const MOCK_TRIPS: Trip[] = [ - { - id: "T001", - title: "Chuyến đi Hoàng Sa", - code: "T001", - vessel: "Hải Âu 1", - vesselCode: "V001", - departureDate: "2025-11-20 06:00", - returnDate: "2025-11-27 18:30", - duration: "7 ngày 12 giờ", - status: "completed", - }, - { - id: "T002", - title: "Tuần tra vùng biển", - code: "T002", - vessel: "Bình Minh", - vesselCode: "V004", - departureDate: "2025-11-26 08:00", - returnDate: null, - duration: "2 ngày 6 giờ", - status: "in-progress", - }, - { - id: "T003", - title: "Đánh cá Trường Sa", - code: "T003", - vessel: "Ngọc Lan", - vesselCode: "V002", - departureDate: "2025-11-15 05:30", - returnDate: "2025-11-25 16:00", - duration: "10 ngày 10 giờ", - status: "completed", - }, - { - id: "T004", - title: "Vận chuyển hàng hóa", - code: "T004", - vessel: "Việt Thắng", - vesselCode: "V003", - departureDate: "2025-11-22 10:00", - returnDate: null, - duration: "-", - status: "cancelled", - }, - { - id: "T005", - title: "Khảo sát địa chất", - code: "T005", - vessel: "Thanh Bình", - vesselCode: "V005", - departureDate: "2025-11-18 07:00", - returnDate: "2025-11-23 14:00", - duration: "5 ngày 7 giờ", - status: "created", - }, - { - id: "T006", - title: "Đánh cá ven bờ", - code: "T006", - vessel: "Hải Âu 1", - vesselCode: "V001", - departureDate: "2025-11-28 04:00", - returnDate: null, - duration: "6 giờ", - status: "in-progress", - }, - { - id: "T007", - title: "Khảo sát vùng biển", - code: "T007", - vessel: "Ngọc Lan", - vesselCode: "V002", - departureDate: "2025-12-01 07:00", - returnDate: null, - duration: "-", - status: "pending", - }, - { - id: "T008", - title: "Đánh cá xa bờ", - code: "T008", - vessel: "Việt Thắng", - vesselCode: "V003", - departureDate: "2025-12-05 05:00", - returnDate: null, - duration: "-", - status: "approved", - }, -]; diff --git a/components/diary/types.ts b/components/diary/types.ts index 5177935..bcd8737 100644 --- a/components/diary/types.ts +++ b/components/diary/types.ts @@ -1,58 +1,93 @@ +import { useI18n } from "@/hooks/use-i18n"; + export type TripStatus = - | "created" // Đã khởi tạo - | "pending" // Chờ duyệt - | "approved" // Đã duyệt - | "in-progress" // Đang hoạt động - | "completed" // Hoàn thành - | "cancelled"; // Đã hủy - -export interface Trip { - id: string; - title: string; - code: string; - vessel: string; - vesselCode: string; - departureDate: string; - returnDate: string | null; - duration: string; - status: TripStatus; -} + | 0 // Đã khởi tạo + | 1 // Chờ duyệt + | 2 // Đã duyệt + | 3 // Đang hoạt động + | 4 // Hoàn thành + | 5; // Đã hủy +// Static config - dùng khi không cần i18n hoặc ngoài React component export const TRIP_STATUS_CONFIG = { - created: { + 0: { label: "Đã khởi tạo", bgColor: "#F3F4F6", // Gray background textColor: "#4B5563", // Gray text icon: "document-text", }, - pending: { + 1: { label: "Chờ duyệt", bgColor: "#FEF3C7", // Yellow background textColor: "#92400E", // Dark yellow text icon: "hourglass", }, - approved: { + 2: { label: "Đã duyệt", bgColor: "#E0E7FF", // Indigo background textColor: "#3730A3", // Dark indigo text icon: "checkmark-done", }, - "in-progress": { + 3: { label: "Đang hoạt động", bgColor: "#DBEAFE", // Blue background textColor: "#1E40AF", // Dark blue text icon: "sync", }, - completed: { + 4: { label: "Hoàn thành", bgColor: "#D1FAE5", // Green background textColor: "#065F46", // Dark green text icon: "checkmark-circle", }, - cancelled: { + 5: { label: "Đã hủy", bgColor: "#FEE2E2", // Red background textColor: "#991B1B", // Dark red text icon: "close-circle", }, } as const; + +// Hook để lấy config với i18n - dùng trong React component +export function useTripStatusConfig() { + const { t } = useI18n(); + + return { + 0: { + label: t("diary.statusDropdown.created"), + bgColor: "#F3F4F6", + textColor: "#4B5563", + icon: "document-text", + }, + 1: { + label: t("diary.statusDropdown.pending"), + bgColor: "#FEF3C7", + textColor: "#92400E", + icon: "hourglass", + }, + 2: { + label: t("diary.statusDropdown.approved"), + bgColor: "#E0E7FF", + textColor: "#3730A3", + icon: "checkmark-done", + }, + 3: { + label: t("diary.statusDropdown.active"), + bgColor: "#DBEAFE", + textColor: "#1E40AF", + icon: "sync", + }, + 4: { + label: t("diary.statusDropdown.completed"), + bgColor: "#D1FAE5", + textColor: "#065F46", + icon: "checkmark-circle", + }, + 5: { + label: t("diary.statusDropdown.cancelled"), + bgColor: "#FEE2E2", + textColor: "#991B1B", + icon: "close-circle", + }, + } as const; +} diff --git a/constants/index.ts b/constants/index.ts index 33f1611..d17b0ec 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -42,6 +42,7 @@ export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo"; export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist"; export const API_GET_LAYER_INFO = "/api/sgw/geojson"; export const API_GET_TRIP = "/api/sgw/trip"; +export const API_POST_TRIPSLIST = "api/sgw/tripslist"; export const API_GET_ALARMS = "/api/io/alarms"; export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState"; export const API_HAUL_HANDLE = "/api/sgw/fishingLog"; diff --git a/controller/TripController.ts b/controller/TripController.ts index b1d87be..d3cd090 100644 --- a/controller/TripController.ts +++ b/controller/TripController.ts @@ -2,6 +2,7 @@ import { api } from "@/config"; import { API_GET_TRIP, API_HAUL_HANDLE, + API_POST_TRIPSLIST, API_UPDATE_FISHING_LOGS, API_UPDATE_TRIP_STATUS, } from "@/constants"; @@ -20,4 +21,8 @@ export async function queryStartNewHaul(body: Model.NewFishingLogRequest) { export async function queryUpdateFishingLogs(body: Model.FishingLog) { return api.put(API_UPDATE_FISHING_LOGS, body); -} \ No newline at end of file +} + +export async function queryTripsList(body: Model.TripListBody) { + return api.post(API_POST_TRIPSLIST, body); +} diff --git a/controller/typings.d.ts b/controller/typings.d.ts index e66f4a8..20c9e4f 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -93,7 +93,37 @@ declare namespace Model { message?: string; started_at?: number; } + // Trip + // Body API trip + interface TripListBody { + name?: string; + order?: string; + dir?: "asc" | "desc"; + limit: number; + offset: number; + metadata?: TripRequestMetadata; + } + + interface TripRequestMetadata { + status?: string; + from?: string; + to?: string; + ship_name?: string; + reg_number?: string; + province_code?: string; + owner_id?: string; + ship_id?: string; + thing_id?: string; + } + + interface TripsListResponse { + total?: number; + offset?: number; + limit?: number; + trips?: Trip[]; + } + interface Trip { id: string; ship_id: string; diff --git a/locales/en.json b/locales/en.json index b4898a8..953f8fd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -60,6 +60,64 @@ "sendError": "Unable to send SOS signal" } }, + "diary": { + "title": "Trip Diary", + "filter": "Filter", + "addTrip": "Add Trip", + "tripList": "Trip List", + "tripListCount": "Trip List ({{count}})", + "noTripsFound": "No matching trips found", + "reset": "Reset", + "apply": "Apply", + "selectedFilters": "Selected filters:", + "statusLabel": "Status:", + "fromLabel": "From:", + "toLabel": "To:", + "shipLabel": "Ship:", + "statusDropdown": { + "label": "Status", + "placeholder": "Please select", + "created": "Created", + "pending": "Pending Approval", + "approved": "Approved", + "active": "Active", + "completed": "Completed", + "cancelled": "Cancelled" + }, + "shipDropdown": { + "label": "Ship", + "placeholder": "Select ship", + "allShips": "All ships", + "searchPlaceholder": "Search ship...", + "noShipsFound": "No matching ships found" + }, + "dateRangePicker": { + "label": "Trip Date", + "startDate": "Start Date", + "endDate": "End Date", + "selectStartDate": "Select start date", + "selectEndDate": "Select end date", + "done": "Done" + }, + "tripCard": { + "shipCode": "Ship Code", + "departure": "Departure", + "return": "Return", + "view": "View", + "edit": "Edit", + "team": "Team", + "send": "Send", + "delete": "Delete" + }, + "tripStatus": { + "created": "Not approved, creating", + "pending": "Pending approval", + "approved": "Approved", + "departed": "Departed", + "completed": "Completed", + "cancelled": "Cancelled" + } + }, "trip": { "infoTrip": "Trip Information", "createNewTrip": "Create New Trip", diff --git a/locales/vi.json b/locales/vi.json index d45f321..b17dc85 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -60,6 +60,64 @@ "sendError": "Không thể gửi tín hiệu SOS" } }, + "diary": { + "title": "Nhật ký chuyến đi", + "filter": "Bộ lọc", + "addTrip": "Thêm chuyến đi", + "tripList": "Danh sách chuyến đi", + "tripListCount": "Danh sách chuyến đi ({{count}})", + "noTripsFound": "Không tìm thấy chuyến đi phù hợp", + "reset": "Đặt lại", + "apply": "Áp dụng", + "selectedFilters": "Bộ lọc đã chọn:", + "statusLabel": "Trạng thái:", + "fromLabel": "Từ:", + "toLabel": "Đến:", + "shipLabel": "Tàu:", + "statusDropdown": { + "label": "Trạng thái", + "placeholder": "Vui lòng chọn", + "created": "Đã khởi tạo", + "pending": "Chờ duyệt", + "approved": "Đã duyệt", + "active": "Đang hoạt động", + "completed": "Hoàn thành", + "cancelled": "Đã hủy" + }, + "shipDropdown": { + "label": "Tàu", + "placeholder": "Chọn tàu", + "allShips": "Tất cả tàu", + "searchPlaceholder": "Tìm kiếm tàu...", + "noShipsFound": "Không tìm thấy tàu phù hợp" + }, + "dateRangePicker": { + "label": "Ngày đi", + "startDate": "Ngày bắt đầu", + "endDate": "Ngày kết thúc", + "selectStartDate": "Chọn ngày bắt đầu", + "selectEndDate": "Chọn ngày kết thúc", + "done": "Xong" + }, + "tripCard": { + "shipCode": "Mã Tàu", + "departure": "Khởi hành", + "return": "Trở về", + "view": "Xem", + "edit": "Sửa", + "team": "Đội", + "send": "Gửi", + "delete": "Xóa" + }, + "tripStatus": { + "created": "Chưa phê duyệt, đang tạo", + "pending": "Đang gửi yêu cầu phê duyệt, chờ được phê duyệt", + "approved": "Đã phê duyệt", + "departed": "Đã xuất bến", + "completed": "Đã hoàn thành", + "cancelled": "Đã huỷ" + } + }, "trip": { "infoTrip": "Thông Tin Chuyến Đi", "createNewTrip": "Tạo chuyến mới", diff --git a/state/use-tripslist.ts b/state/use-tripslist.ts new file mode 100644 index 0000000..3e51fdb --- /dev/null +++ b/state/use-tripslist.ts @@ -0,0 +1,31 @@ +import { queryTripsList } from "@/controller/TripController"; +import { create } from "zustand"; + +type TripsListState = { + tripsList: Model.TripsListResponse | null; + getTripsList: (body: Model.TripListBody) => Promise; + error: string | null; + loading?: boolean; +}; + +export const useTripsList = create((set) => ({ + tripsList: null, + getTripsList: async (body: Model.TripListBody) => { + set({ loading: true, error: null }); + try { + const response = await queryTripsList(body); + console.log("Trip fetching API: ", response.data.trips?.length); + + set({ tripsList: response.data ?? [], loading: false }); + } catch (error) { + console.error("Error when fetch things: ", error); + set({ + error: "Failed to fetch things data", + loading: false, + tripsList: null, + }); + } + }, + error: null, + loading: false, +}));