diff --git a/app/(tabs)/diary.tsx b/app/(tabs)/diary.tsx index cabcbda..9ca7db4 100644 --- a/app/(tabs)/diary.tsx +++ b/app/(tabs)/diary.tsx @@ -14,7 +14,7 @@ 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 AddTripModal from "@/components/diary/TripFormModal"; +import TripFormModal from "@/components/diary/TripFormModal"; import { useThings } from "@/state/use-thing"; import { useTripsList } from "@/state/use-tripslist"; import dayjs from "dayjs"; @@ -26,7 +26,7 @@ export default function diary() { const { colors } = useThemeContext(); const router = useRouter(); const [showFilterModal, setShowFilterModal] = useState(false); - const [showAddTripModal, setShowAddTripModal] = useState(false); + const [showTripFormModal, setShowTripFormModal] = useState(false); const [editingTrip, setEditingTrip] = useState(null); const [filters, setFilters] = useState({ status: null, @@ -41,7 +41,7 @@ export default function diary() { const [hasMore, setHasMore] = useState(true); const isInitialLoad = useRef(true); const flatListRef = useRef(null); - + // Refs to prevent duplicate API calls from React Compiler/Strict Mode const hasInitializedThings = useRef(false); const hasInitializedTrips = useRef(false); @@ -193,9 +193,9 @@ export default function diary() { if (tripToView) { router.push({ pathname: "/trip-detail", - params: { - tripId: tripToView.id, - tripData: JSON.stringify(tripToView) + params: { + tripId: tripToView.id, + tripData: JSON.stringify(tripToView), }, }); } @@ -206,7 +206,7 @@ export default function diary() { const tripToEdit = allTrips.find((trip) => trip.id === tripId); if (tripToEdit) { setEditingTrip(tripToEdit); - setShowAddTripModal(true); + setShowTripFormModal(true); } }; @@ -351,7 +351,7 @@ export default function diary() { /> setShowAddTripModal(true)} + onPress={() => setShowTripFormModal(true)} activeOpacity={0.7} > @@ -391,10 +391,10 @@ export default function diary() { /> {/* Add/Edit Trip Modal */} - { - setShowAddTripModal(false); + setShowTripFormModal(false); setEditingTrip(null); }} onSuccess={handleTripAddSuccess} diff --git a/app/trip-detail.tsx b/app/trip-detail.tsx index a4c3b81..9a2c36b 100644 --- a/app/trip-detail.tsx +++ b/app/trip-detail.tsx @@ -157,7 +157,7 @@ export default function TripDetailPage() { > {tripCosts.length > 0 ? ( - {}} disabled /> + {}} disabled hideTitle /> ) : ( @@ -174,7 +174,7 @@ export default function TripDetailPage() { > {fishingGears.length > 0 ? ( - {}} disabled /> + {}} disabled hideTitle /> ) : ( @@ -297,7 +297,7 @@ const styles = StyleSheet.create({ fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), }, sectionInnerContent: { - marginTop: -8, + marginTop: 5, }, emptySection: { alignItems: "center", diff --git a/components/diary/TripFormModal/FishingGearList.tsx b/components/diary/TripFormModal/FishingGearList.tsx index 3fe59ed..299d36b 100644 --- a/components/diary/TripFormModal/FishingGearList.tsx +++ b/components/diary/TripFormModal/FishingGearList.tsx @@ -21,12 +21,14 @@ interface FishingGearListProps { items: FishingGear[]; onChange: (items: FishingGear[]) => void; disabled?: boolean; + hideTitle?: boolean; } export default function FishingGearList({ items, onChange, disabled = false, + hideTitle = false, }: FishingGearListProps) { const { t } = useI18n(); const { colors } = useThemeContext(); @@ -77,9 +79,11 @@ export default function FishingGearList({ return ( - - {t("diary.fishingGearList")} - + {!hideTitle && ( + + {t("diary.fishingGearList")} + + )} {/* Gear Items List */} {items.map((gear, index) => ( diff --git a/components/diary/TripFormModal/FormSection.tsx b/components/diary/TripFormModal/FormSection.tsx new file mode 100644 index 0000000..253ab68 --- /dev/null +++ b/components/diary/TripFormModal/FormSection.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { View, Text, StyleSheet, Platform } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useThemeContext } from "@/hooks/use-theme-context"; + +interface FormSectionProps { + title: string; + icon?: keyof typeof Ionicons.glyphMap; + children: React.ReactNode; +} + +/** + * A styled section wrapper for grouping related form fields + */ +export default function FormSection({ title, icon, children }: FormSectionProps) { + const { colors } = useThemeContext(); + + return ( + + + {icon && ( + + )} + {title} + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 20, + borderBottomWidth: 1, + paddingBottom: 16, + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: 16, + }, + icon: { + marginRight: 8, + }, + title: { + fontSize: 17, + fontWeight: "700", + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + content: { + gap: 0, + }, +}); diff --git a/components/diary/TripFormModal/MaterialCostList.tsx b/components/diary/TripFormModal/MaterialCostList.tsx index dedda1e..9e982cc 100644 --- a/components/diary/TripFormModal/MaterialCostList.tsx +++ b/components/diary/TripFormModal/MaterialCostList.tsx @@ -26,6 +26,7 @@ interface MaterialCostListProps { items: TripCost[]; onChange: (items: TripCost[]) => void; disabled?: boolean; + hideTitle?: boolean; } // Predefined cost types @@ -41,6 +42,7 @@ export default function MaterialCostList({ items, onChange, disabled = false, + hideTitle = false, }: MaterialCostListProps) { const { t } = useI18n(); const { colors } = useThemeContext(); @@ -133,9 +135,11 @@ export default function MaterialCostList({ return ( - - {t("trip.costTable.title")} - + {!hideTitle && ( + + {t("trip.costTable.title")} + + )} {/* Cost Items List */} {items.map((cost) => ( diff --git a/components/diary/TripFormModal/PortSelector.tsx b/components/diary/TripFormModal/PortSelector.tsx index 5076a4c..b7ee07e 100644 --- a/components/diary/TripFormModal/PortSelector.tsx +++ b/components/diary/TripFormModal/PortSelector.tsx @@ -1,14 +1,21 @@ -import React from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { View, Text, TouchableOpacity, StyleSheet, Platform, + Modal, + ScrollView, + TextInput, + ActivityIndicator, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useI18n } from "@/hooks/use-i18n"; import { useThemeContext } from "@/hooks/use-theme-context"; +import { useGroup } from "@/state/use-group"; +import { usePort } from "@/state/use-ports"; +import { filterPortsByProvinceCode } from "@/utils/tripDataConverters"; interface PortSelectorProps { departurePortId: number; @@ -18,6 +25,8 @@ interface PortSelectorProps { disabled?: boolean; } +type PortType = "departure" | "arrival"; + export default function PortSelector({ departurePortId, arrivalPortId, @@ -27,27 +36,71 @@ export default function PortSelector({ }: PortSelectorProps) { const { t } = useI18n(); const { colors } = useThemeContext(); + + // State from zustand stores + const { groups, getUserGroups, loading: groupsLoading } = useGroup(); + const { ports, getPorts, loading: portsLoading } = usePort(); + + // Local state - which dropdown is open + const [activeDropdown, setActiveDropdown] = useState(null); + const [searchText, setSearchText] = useState(""); - const handleSelectDeparturePort = () => { - console.log("Select departure port pressed"); - // TODO: Implement port selection modal/dropdown - // For now, just set a dummy ID - onDeparturePortChange(1); + // Fetch groups and ports if not available + useEffect(() => { + if (!groups) { + getUserGroups(); + } + }, [groups, getUserGroups]); + + useEffect(() => { + if (!ports) { + getPorts(); + } + }, [ports, getPorts]); + + // Filter ports by province codes from groups + const filteredPorts = useMemo(() => { + return filterPortsByProvinceCode(ports, groups); + }, [ports, groups]); + + // Filter by search text + const searchFilteredPorts = useMemo(() => { + if (!searchText) return filteredPorts; + return filteredPorts.filter((port) => + port.name?.toLowerCase().includes(searchText.toLowerCase()) + ); + }, [filteredPorts, searchText]); + + const isLoading = groupsLoading || portsLoading; + + const handleSelectPort = (portId: number) => { + if (activeDropdown === "departure") { + onDeparturePortChange(portId); + } else { + onArrivalPortChange(portId); + } + setActiveDropdown(null); + setSearchText(""); }; - const handleSelectArrivalPort = () => { - console.log("Select arrival port pressed"); - // TODO: Implement port selection modal/dropdown - // For now, just set a dummy ID - onArrivalPortChange(1); + const openDropdown = (type: PortType) => { + setActiveDropdown(type); + setSearchText(""); }; - // Helper to display port name (in production, fetch from port list by ID) + const closeDropdown = () => { + setActiveDropdown(null); + setSearchText(""); + }; + + // Get port name by ID const getPortDisplayName = (portId: number): string => { - // TODO: Fetch actual port name by ID from port list - return portId ? `Cảng (ID: ${portId})` : t("diary.selectPort"); + const port = filteredPorts.find((p) => p.id === portId); + return port?.name || t("diary.selectPort"); }; + const currentSelectedId = activeDropdown === "departure" ? departurePortId : arrivalPortId; + const themedStyles = { label: { color: colors.text }, portSelector: { @@ -56,6 +109,15 @@ export default function PortSelector({ }, portText: { color: colors.text }, placeholder: { color: colors.textSecondary }, + modalOverlay: { backgroundColor: "rgba(0, 0, 0, 0.5)" }, + modalContent: { backgroundColor: colors.card }, + searchInput: { + backgroundColor: colors.backgroundSecondary, + color: colors.text, + borderColor: colors.border, + }, + option: { borderBottomColor: colors.separator }, + optionText: { color: colors.text }, }; return ( @@ -70,26 +132,32 @@ export default function PortSelector({ {t("diary.departurePort")} openDropdown("departure")} activeOpacity={disabled ? 1 : 0.7} disabled={disabled} > - - {getPortDisplayName(departurePortId)} - - {!disabled && ( - + {isLoading ? ( + + ) : ( + <> + + {getPortDisplayName(departurePortId)} + + {!disabled && ( + + )} + )} @@ -100,30 +168,99 @@ export default function PortSelector({ {t("diary.arrivalPort")} openDropdown("arrival")} activeOpacity={disabled ? 1 : 0.7} disabled={disabled} > - - {getPortDisplayName(arrivalPortId)} - - {!disabled && ( - + {isLoading ? ( + + ) : ( + <> + + {getPortDisplayName(arrivalPortId)} + + {!disabled && ( + + )} + )} + + {/* Port Dropdown Modal - Fade style like MaterialCostList */} + + + + {/* Search Input */} + + + + + + {/* Port List */} + + {isLoading ? ( + + + + ) : searchFilteredPorts.length === 0 ? ( + + + {t("diary.noPortsFound")} + + + ) : ( + searchFilteredPorts.map((port) => ( + handleSelectPort(port.id || 0)} + > + + {port.name} + + {currentSelectedId === port.id && ( + + )} + + )) + )} + + + + ); } @@ -152,22 +289,23 @@ const styles = StyleSheet.create({ }), }, portContainer: { - flexDirection: "row", + flexDirection: "column", gap: 12, }, portSection: { flex: 1, }, - portSelector: { + dropdown: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, - paddingVertical: 12, + paddingVertical: 10, + minHeight: 44, }, - portText: { + dropdownText: { fontSize: 15, flex: 1, fontFamily: Platform.select({ @@ -176,4 +314,77 @@ const styles = StyleSheet.create({ default: "System", }), }, + // Modal styles - same as MaterialCostList + modalOverlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + modalContent: { + borderRadius: 12, + width: "85%", + maxHeight: "60%", + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + searchContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: "#E5E5E7", + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 15, + paddingVertical: 6, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + optionsList: { + maxHeight: 300, + }, + option: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + }, + optionText: { + fontSize: 16, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + loadingContainer: { + padding: 20, + alignItems: "center", + }, + emptyContainer: { + padding: 20, + alignItems: "center", + }, + emptyText: { + fontSize: 14, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, }); diff --git a/components/diary/TripFormModal/index.tsx b/components/diary/TripFormModal/index.tsx index ec43f1d..081573e 100644 --- a/components/diary/TripFormModal/index.tsx +++ b/components/diary/TripFormModal/index.tsx @@ -23,6 +23,7 @@ import PortSelector from "./PortSelector"; import BasicInfoInput from "./BasicInfoInput"; import ShipSelector from "./ShipSelector"; import AutoFillSection from "./AutoFillSection"; +import FormSection from "./FormSection"; import { createTrip, updateTrip } from "@/controller/TripController"; import { convertFishingGears, @@ -80,15 +81,31 @@ export default function TripFormModal({ ).current; // Form state - const [selectedShipId, setSelectedShipId] = useState(DEFAULT_FORM_STATE.selectedShipId); + const [selectedShipId, setSelectedShipId] = useState( + DEFAULT_FORM_STATE.selectedShipId + ); const [tripName, setTripName] = useState(DEFAULT_FORM_STATE.tripName); - const [fishingGears, setFishingGears] = useState(DEFAULT_FORM_STATE.fishingGears); - const [tripCosts, setTripCosts] = useState(DEFAULT_FORM_STATE.tripCosts); - const [startDate, setStartDate] = useState(DEFAULT_FORM_STATE.startDate); - const [endDate, setEndDate] = useState(DEFAULT_FORM_STATE.endDate); - const [departurePortId, setDeparturePortId] = useState(DEFAULT_FORM_STATE.departurePortId); - const [arrivalPortId, setArrivalPortId] = useState(DEFAULT_FORM_STATE.arrivalPortId); - const [fishingGroundCodes, setFishingGroundCodes] = useState(DEFAULT_FORM_STATE.fishingGroundCodes); + const [fishingGears, setFishingGears] = useState( + DEFAULT_FORM_STATE.fishingGears + ); + const [tripCosts, setTripCosts] = useState( + DEFAULT_FORM_STATE.tripCosts + ); + const [startDate, setStartDate] = useState( + DEFAULT_FORM_STATE.startDate + ); + const [endDate, setEndDate] = useState( + DEFAULT_FORM_STATE.endDate + ); + const [departurePortId, setDeparturePortId] = useState( + DEFAULT_FORM_STATE.departurePortId + ); + const [arrivalPortId, setArrivalPortId] = useState( + DEFAULT_FORM_STATE.arrivalPortId + ); + const [fishingGroundCodes, setFishingGroundCodes] = useState( + DEFAULT_FORM_STATE.fishingGroundCodes + ); const [isSubmitting, setIsSubmitting] = useState(false); // Reset form to default state @@ -105,17 +122,22 @@ export default function TripFormModal({ }, []); // Fill form with trip data - const fillFormWithTripData = useCallback((data: Model.Trip, prefix: string) => { - setSelectedShipId(data.vms_id || ""); - setTripName(data.name || ""); - setFishingGears(convertFishingGears(data.fishing_gears, prefix)); - setTripCosts(convertTripCosts(data.trip_cost, prefix)); - if (data.departure_time) setStartDate(new Date(data.departure_time)); - if (data.arrival_time) setEndDate(new Date(data.arrival_time)); - if (data.departure_port_id) setDeparturePortId(data.departure_port_id); - if (data.arrival_port_id) setArrivalPortId(data.arrival_port_id); - setFishingGroundCodes(convertFishingGroundCodes(data.fishing_ground_codes)); - }, []); + const fillFormWithTripData = useCallback( + (data: Model.Trip, prefix: string) => { + setSelectedShipId(data.vms_id || ""); + setTripName(data.name || ""); + setFishingGears(convertFishingGears(data.fishing_gears, prefix)); + setTripCosts(convertTripCosts(data.trip_cost, prefix)); + if (data.departure_time) setStartDate(new Date(data.departure_time)); + if (data.arrival_time) setEndDate(new Date(data.arrival_time)); + if (data.departure_port_id) setDeparturePortId(data.departure_port_id); + if (data.arrival_port_id) setArrivalPortId(data.arrival_port_id); + setFishingGroundCodes( + convertFishingGroundCodes(data.fishing_ground_codes) + ); + }, + [] + ); // Handle animation when modal visibility changes useEffect(() => { @@ -185,9 +207,12 @@ export default function TripFormModal({ setSelectedShipId(selectedThingId); setFishingGears(convertFishingGears(tripData.fishing_gears, "auto")); setTripCosts(convertTripCosts(tripData.trip_cost, "auto")); - if (tripData.departure_port_id) setDeparturePortId(tripData.departure_port_id); + if (tripData.departure_port_id) + setDeparturePortId(tripData.departure_port_id); if (tripData.arrival_port_id) setArrivalPortId(tripData.arrival_port_id); - setFishingGroundCodes(convertFishingGroundCodes(tripData.fishing_ground_codes)); + setFishingGroundCodes( + convertFishingGroundCodes(tripData.fishing_ground_codes) + ); }, [] ); @@ -323,47 +348,60 @@ export default function TripFormModal({ {/* Content */} - + {/* Auto Fill Section - only show in add mode */} {!isEditMode && } - {/* Ship Selector - disabled in edit mode */} - + {/* Section 1: Basic Information */} + + {/* Ship Selector - disabled in edit mode */} + - {/* Trip Name */} - + {/* Trip Name */} + + - {/* Fishing Gear List */} - + {/* Section 2: Schedule & Location */} + + {/* Trip Duration */} + - {/* Trip Cost List */} - + {/* Port Selector */} + - {/* Trip Duration */} - + {/* Fishing Ground Codes */} + + - {/* Port Selector */} - + {/* Section 3: Equipment */} + + + - {/* Fishing Ground Codes */} - + {/* Section 4: Costs */} + + + {/* Footer */} @@ -373,7 +411,9 @@ export default function TripFormModal({ onPress={handleCancel} activeOpacity={0.7} > - + {t("common.cancel")} diff --git a/locales/vi.json b/locales/vi.json index ea37bd8..95a0206 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -150,13 +150,21 @@ "selectDate": "Chọn ngày", "selectStartDate": "Chọn ngày bắt đầu", "selectEndDate": "Chọn ngày kết thúc", - "portLabel": " Cả", + "portLabel": " Cảng", "departurePort": "Cảng khởi hành", "arrivalPort": "Cảng cập bến", "selectPort": "Chọn cảng", + "searchPort": "Tìm kiếm cảng...", + "noPortsFound": "Không tìm thấy cảng phù hợp", "fishingGroundCodes": "Ô ngư trường khai thác", "fishingGroundCodesHint": "Nhập mã ô ngư trường (cách nhau bằng dấu phẩy)", "fishingGroundCodesPlaceholder": "Ví dụ: 1,2,3", + "formSection": { + "basicInfo": "Thông tin cơ bản", + "schedule": "Lịch trình & Vị trí", + "equipment": "Ngư cụ", + "costs": "Chi phí chuyến đi" + }, "autoFill": { "title": "Tự động điền dữ liệu", "description": "Điền từ chuyến đi cuối cùng của tàu", diff --git a/utils/tripDataConverters.ts b/utils/tripDataConverters.ts index 82bda0d..ecad259 100644 --- a/utils/tripDataConverters.ts +++ b/utils/tripDataConverters.ts @@ -52,3 +52,43 @@ export function parseFishingGroundCodes(codesString: string): number[] { .map((code) => parseInt(code.trim())) .filter((code) => !isNaN(code)); } + +/** + * Extract province codes from groups + * @param groups - Model.GroupResponse containing groups with metadata.code + * @returns Array of province codes + */ +export function getProvinceCodesFromGroups( + groups: Model.GroupResponse | null | undefined +): string[] { + if (!groups?.groups) return []; + + return groups.groups + .map((group) => group.metadata?.code) + .filter((code): code is string => !!code); +} + +/** + * Filter ports by province codes extracted from groups + * @param ports - Model.PortResponse containing all ports + * @param groups - Model.GroupResponse to extract province_code from metadata.code + * @returns Filtered ports that match the province codes from groups + */ +export function filterPortsByProvinceCode( + ports: Model.PortResponse | null | undefined, + groups: Model.GroupResponse | null | undefined +): Model.Port[] { + if (!ports?.ports) return []; + if (!groups?.groups) return ports.ports; // Return all ports if no groups + + // Extract province codes from groups + const provinceCodes = getProvinceCodesFromGroups(groups); + + // If no province codes found, return all ports + if (provinceCodes.length === 0) return ports.ports; + + // Filter ports by province codes + return ports.ports.filter( + (port) => port.province_code && provinceCodes.includes(port.province_code) + ); +}