update tab nhật ký (Lọc, Ngôn ngữ,...)

This commit is contained in:
2025-12-07 20:23:10 +07:00
parent 0672f8adf9
commit e405a0bcfa
17 changed files with 851 additions and 332 deletions

View File

@@ -12,7 +12,7 @@ export default function TabLayout() {
const segments = useSegments() as string[];
const prev = useRef<string | null>(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 }) => (
<IconSymbol size={28} name="square.stack.3d.up.fill" color={color} />
<IconSymbol
size={28}
name="square.stack.3d.up.fill"
color={color}
/>
),
}}
/>

View File

@@ -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<FilterValues>({
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<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 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 (
<SafeAreaView style={styles.safeArea}>
<SafeAreaView style={styles.safeArea} edges={["top"]}>
<View style={styles.container}>
{/* Header */}
<Text style={styles.titleText}>Nhật chuyến đi</Text>
<Text style={styles.titleText}>{t("diary.title")}</Text>
{/* Search Bar */}
<SearchBar onSearch={handleSearch} style={{ marginBottom: 10 }} />
{/* Filter Button */}
<FilterButton onPress={handleFilter} />
{/* Trip Count & Add Button */}
<View style={styles.headerRow}>
<Text style={styles.countText}>
Danh sách chuyến đi ({filteredTrips.length})
</Text>
{/* 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}
onPress={() => console.log("Add trip")}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color="#FFFFFF" />
<Text style={styles.addButtonText}>Thêm chuyến đi</Text>
<Text style={styles.addButtonText}>{t("diary.addTrip")}</Text>
</TouchableOpacity>
</View>
{/* Trip Count */}
<Text style={styles.countText}>
{t("diary.tripListCount", { count: tripsList?.total || 0 })}
</Text>
{/* Trip List */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{filteredTrips.map((trip) => (
{tripsList?.trips?.map((trip) => (
<TripCard
key={trip.id}
trip={trip}
@@ -169,10 +187,10 @@ export default function diary() {
/>
))}
{filteredTrips.length === 0 && (
{(!tripsList || !tripsList.trips || tripsList.trips.length === 0) && (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>
Không tìm thấy chuyến đi phù hợp
{t("diary.noTripsFound")}
</Text>
</View>
)}
@@ -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",

View File

@@ -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 (
<View style={styles.container}>
<Text style={styles.label}>Ngày đi</Text>
<Text style={styles.label}>{t("diary.dateRangePicker.label")}</Text>
<View style={styles.dateRangeContainer}>
{/* Start Date */}
<TouchableOpacity
@@ -59,7 +61,7 @@ export default function DateRangePicker({
activeOpacity={0.7}
>
<Text style={[styles.dateText, !startDate && styles.placeholder]}>
{startDate ? formatDate(startDate) : "Ngày bắt đầu"}
{startDate ? formatDate(startDate) : t("diary.dateRangePicker.startDate")}
</Text>
</TouchableOpacity>
@@ -77,7 +79,7 @@ export default function DateRangePicker({
activeOpacity={0.7}
>
<Text style={[styles.dateText, !endDate && styles.placeholder]}>
{endDate ? formatDate(endDate) : "Ngày kết thúc"}
{endDate ? formatDate(endDate) : t("diary.dateRangePicker.endDate")}
</Text>
</TouchableOpacity>
@@ -96,11 +98,11 @@ export default function DateRangePicker({
<View style={styles.pickerContainer}>
<View style={styles.pickerHeader}>
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
<Text style={styles.cancelButton}>Hủy</Text>
<Text style={styles.cancelButton}>{t("common.cancel")}</Text>
</TouchableOpacity>
<Text style={styles.pickerTitle}>Chọn ngày bắt đu</Text>
<Text style={styles.pickerTitle}>{t("diary.dateRangePicker.selectStartDate")}</Text>
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
<Text style={styles.doneButton}>Xong</Text>
<Text style={styles.doneButton}>{t("diary.dateRangePicker.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
@@ -122,11 +124,11 @@ export default function DateRangePicker({
<View style={styles.pickerContainer}>
<View style={styles.pickerHeader}>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={styles.cancelButton}>Hủy</Text>
<Text style={styles.cancelButton}>{t("common.cancel")}</Text>
</TouchableOpacity>
<Text style={styles.pickerTitle}>Chọn ngày kết thúc</Text>
<Text style={styles.pickerTitle}>{t("diary.dateRangePicker.selectEndDate")}</Text>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={styles.doneButton}>Xong</Text>
<Text style={styles.doneButton}>{t("diary.dateRangePicker.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker

View File

@@ -1,20 +1,41 @@
import React from "react";
import { TouchableOpacity, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
interface FilterButtonProps {
onPress?: () => void;
isFiltered?: boolean;
}
export default function FilterButton({ onPress }: FilterButtonProps) {
export default function FilterButton({
onPress,
isFiltered,
}: FilterButtonProps) {
const { t } = useI18n();
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.7}
>
<Ionicons name="filter" size={20} color="#374151" />
<Text style={styles.text}>Bộ lọc</Text>
<Ionicons
name="filter"
size={20}
color={isFiltered ? "#3B82F6" : "#374151"}
/>
<Text style={[styles.text, isFiltered && { color: "#3B82F6" }]}>
{t("diary.filter")}
</Text>
{isFiltered && (
<Ionicons
name="ellipse"
size={10}
color="#3B82F6"
style={{ marginLeft: 4 }}
/>
)}
</TouchableOpacity>
);
}

View File

@@ -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<TripStatus | null>(null);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [selectedShip, setSelectedShip] = useState<ShipOption | null>(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 (
<Modal
@@ -69,7 +109,7 @@ export default function FilterModal({
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color="#111827" />
</TouchableOpacity>
<Text style={styles.title}>Bộ lọc</Text>
<Text style={styles.title}>{t("diary.filter")}</Text>
<View style={styles.placeholder} />
</View>
@@ -85,29 +125,37 @@ export default function FilterModal({
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<ShipDropdown value={selectedShip} onChange={setSelectedShip} />
{/* Filter Results Preview */}
{hasFilters && (
<View style={styles.previewContainer}>
<Text style={styles.previewTitle}>Bộ lọc đã chọn:</Text>
{status && (
<Text style={styles.previewTitle}>{t("diary.selectedFilters")}</Text>
{status !== null && (
<View style={styles.filterTag}>
<Text style={styles.filterTagText}>
Trạng thái: {status}
{t("diary.statusLabel")} {mapStatusNumberToString(status)}
</Text>
</View>
)}
{startDate && (
<View style={styles.filterTag}>
<Text style={styles.filterTagText}>
Từ: {startDate.toLocaleDateString("vi-VN")}
{t("diary.fromLabel")} {startDate.toLocaleDateString("vi-VN")}
</Text>
</View>
)}
{endDate && (
<View style={styles.filterTag}>
<Text style={styles.filterTagText}>
Đến: {endDate.toLocaleDateString("vi-VN")}
{t("diary.toLabel")} {endDate.toLocaleDateString("vi-VN")}
</Text>
</View>
)}
{selectedShip && (
<View style={styles.filterTag}>
<Text style={styles.filterTagText}>
{t("diary.shipLabel")} {selectedShip.shipName}
</Text>
</View>
)}
@@ -122,14 +170,14 @@ export default function FilterModal({
onPress={handleReset}
activeOpacity={0.7}
>
<Text style={styles.resetButtonText}>Đt lại</Text>
<Text style={styles.resetButtonText}>{t("diary.reset")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.applyButton}
onPress={handleApply}
activeOpacity={0.7}
>
<Text style={styles.applyButtonText}>Áp dụng</Text>
<Text style={styles.applyButtonText}>{t("diary.apply")}</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>

View File

@@ -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<ViewStyle>;
}
export default function SearchBar({ onSearch, style }: SearchBarProps) {
const [searchText, setSearchText] = useState("");
const handleChangeText = (text: string) => {
setSearchText(text);
onSearch?.(text);
};
return (
<View style={[styles.container, style]}>
<Ionicons name="search" size={20} color="#9CA3AF" style={styles.icon} />
<TextInput
style={styles.input}
placeholder="Tìm kiếm chuyến đi, tàu..."
placeholderTextColor="#9CA3AF"
value={searchText}
onChangeText={handleChangeText}
/>
{searchText.length > 0 && (
<Ionicons
name="close-circle"
size={20}
color="#9CA3AF"
style={styles.clearIcon}
onPress={() => handleChangeText("")}
/>
)}
</View>
);
}
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,
},
});

View File

@@ -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 (
<View style={styles.container}>
<Text style={styles.label}>{t("diary.shipDropdown.label")}</Text>
<TouchableOpacity
style={styles.selector}
onPress={() => setIsOpen(true)}
activeOpacity={0.7}
>
<Text style={[styles.selectorText, !value && styles.placeholder]}>
{displayValue}
</Text>
<Ionicons name="chevron-down" size={20} color="#6B7280" />
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => setIsOpen(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setIsOpen(false)}
>
<View
style={styles.modalContent}
onStartShouldSetResponder={() => true}
>
{/* Search Input */}
<View style={styles.searchContainer}>
<Ionicons
name="search"
size={20}
color="#9CA3AF"
style={styles.searchIcon}
/>
<TextInput
style={styles.searchInput}
placeholder={t("diary.shipDropdown.searchPlaceholder")}
placeholderTextColor="#9CA3AF"
value={searchText}
onChangeText={setSearchText}
autoCapitalize="none"
/>
{searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText("")}>
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
</TouchableOpacity>
)}
</View>
<ScrollView style={styles.optionsList}>
{/* Option to clear selection */}
<TouchableOpacity
style={[styles.option, !value && styles.selectedOption]}
onPress={() => handleSelect(null)}
>
<Text
style={[
styles.optionText,
styles.placeholderOption,
!value && styles.selectedOptionText,
]}
>
{t("diary.shipDropdown.allShips")}
</Text>
{!value && (
<Ionicons name="checkmark" size={20} color="#3B82F6" />
)}
</TouchableOpacity>
{filteredShips.map((ship) => (
<TouchableOpacity
key={ship.id}
style={[
styles.option,
value?.id === ship.id && styles.selectedOption,
]}
onPress={() => handleSelect(ship)}
>
<View style={styles.shipInfo}>
<Text
style={[
styles.shipName,
value?.id === ship.id && styles.selectedOptionText,
]}
>
{ship.shipName}
</Text>
</View>
{value?.id === ship.id && (
<Ionicons name="checkmark" size={20} color="#3B82F6" />
)}
</TouchableOpacity>
))}
{filteredShips.length === 0 && searchText.length > 0 && (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>
{t("diary.shipDropdown.noShipsFound")}
</Text>
</View>
)}
</ScrollView>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
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",
}),
},
});

View File

@@ -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 (
<View style={styles.container}>
<Text style={styles.label}>Trạng thái</Text>
<Text style={styles.label}>{t("diary.statusDropdown.label")}</Text>
<TouchableOpacity
style={styles.selector}
onPress={() => setIsOpen(true)}

View File

@@ -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 (
<View style={styles.card}>
@@ -39,8 +66,7 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
color={statusConfig.textColor}
/>
<View style={styles.titleContainer}>
<Text style={styles.title}>{trip.title}</Text>
<Text style={styles.code}>{trip.code}</Text>
<Text style={styles.title}>{trip.name}</Text>
</View>
</View>
<View
@@ -67,25 +93,29 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
{/* Info Grid */}
<View style={styles.infoGrid}>
<View style={styles.infoRow}>
<Text style={styles.label}>Tàu</Text>
<Text style={styles.label}>{t("diary.tripCard.shipCode")}</Text>
<Text style={styles.value}>
{trip.vessel} ({trip.vesselCode})
{thingOfTrip?.metadata?.ship_reg_number /* hoặc trip.ship_id */}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Khởi hành</Text>
<Text style={styles.value}>{trip.departureDate}</Text>
<Text style={styles.label}>{t("diary.tripCard.departure")}</Text>
<Text style={styles.value}>
{trip.departure_time
? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm")
: "-"}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Trở về</Text>
<Text style={styles.value}>{trip.returnDate || "-"}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Thời gian</Text>
<Text style={[styles.value, styles.duration]}>{trip.duration}</Text>
<Text style={styles.label}>{t("diary.tripCard.return")}</Text>
{/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */}
<Text style={styles.value}>
{trip.arrival_time
? dayjs(trip.arrival_time).format("DD/MM/YYYY HH:mm")
: "-"}
</Text>
</View>
</View>
</TouchableOpacity>
@@ -93,34 +123,54 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
{/* Action Buttons */}
<View style={styles.divider} />
<View style={styles.actionsContainer}>
<TouchableOpacity style={styles.actionButton} onPress={onView} activeOpacity={0.7}>
<TouchableOpacity
style={styles.actionButton}
onPress={onView}
activeOpacity={0.7}
>
<Ionicons name="eye-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>View</Text>
<Text style={styles.actionText}>{t("diary.tripCard.view")}</Text>
</TouchableOpacity>
{showEdit && (
<TouchableOpacity style={styles.actionButton} onPress={onEdit} activeOpacity={0.7}>
<TouchableOpacity
style={styles.actionButton}
onPress={onEdit}
activeOpacity={0.7}
>
<Ionicons name="create-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Edit</Text>
<Text style={styles.actionText}>{t("diary.tripCard.edit")}</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={onTeam} activeOpacity={0.7}>
<TouchableOpacity
style={styles.actionButton}
onPress={onTeam}
activeOpacity={0.7}
>
<Ionicons name="people-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Team</Text>
<Text style={styles.actionText}>{t("diary.tripCard.team")}</Text>
</TouchableOpacity>
{showSend && (
<TouchableOpacity style={styles.actionButton} onPress={onSend} activeOpacity={0.7}>
<TouchableOpacity
style={styles.actionButton}
onPress={onSend}
activeOpacity={0.7}
>
<Ionicons name="send-outline" size={20} color="#6B7280" />
<Text style={styles.actionText}>Send</Text>
<Text style={styles.actionText}>{t("diary.tripCard.send")}</Text>
</TouchableOpacity>
)}
{showDelete && (
<TouchableOpacity style={styles.actionButton} onPress={onDelete} activeOpacity={0.7}>
<TouchableOpacity
style={styles.actionButton}
onPress={onDelete}
activeOpacity={0.7}
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text style={[styles.actionText, styles.deleteText]}>Delete</Text>
<Text style={[styles.actionText, styles.deleteText]}>{t("diary.tripCard.delete")}</Text>
</TouchableOpacity>
)}
</View>
@@ -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",

View File

@@ -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",
},
];

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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";
@@ -21,3 +22,7 @@ export async function queryStartNewHaul(body: Model.NewFishingLogRequest) {
export async function queryUpdateFishingLogs(body: Model.FishingLog) {
return api.put(API_UPDATE_FISHING_LOGS, body);
}
export async function queryTripsList(body: Model.TripListBody) {
return api.post(API_POST_TRIPSLIST, body);
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

31
state/use-tripslist.ts Normal file
View File

@@ -0,0 +1,31 @@
import { queryTripsList } from "@/controller/TripController";
import { create } from "zustand";
type TripsListState = {
tripsList: Model.TripsListResponse | null;
getTripsList: (body: Model.TripListBody) => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useTripsList = create<TripsListState>((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,
}));