diff --git a/add_trip_modal_summary.md b/add_trip_modal_summary.md
new file mode 100644
index 0000000..5365762
--- /dev/null
+++ b/add_trip_modal_summary.md
@@ -0,0 +1,277 @@
+# Add Trip Modal - Implementation Summary
+
+## Overview
+Đã tạo thành công modal **Add Trip** (Thêm chuyến đi) cho ứng dụng di động với cấu trúc component rõ ràng, UI hoàn chỉnh, và chức năng ghi log thay vì call API thực.
+
+## Uploaded Form Reference
+
+
+## Component Structure
+
+### Main Modal Component
+**[index.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/index.tsx)**
+- Component chính quản lý toàn bộ modal
+- Quản lý state của form (trip name, fishing gears, material costs, dates, ports, initial stock)
+- Xử lý submit: Log dữ liệu thay vì call API
+- Bao gồm header, scrollable content, và footer với các nút hành động
+
+### Sub-Components
+
+#### 1. **[TripNameInput.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/TripNameInput.tsx)**
+- Input field để nhập tên chuyến đi
+- Props: `value`, `onChange`
+- Hỗ trợ theme động
+
+#### 2. **[FishingGearList.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/FishingGearList.tsx)**
+- Danh sách ngư cụ với khả năng thêm/xóa
+- Nút "Thêm ngư cụ" với border dashed
+- Hiển thị từng item với tên và số lượng
+- Có nút xóa cho mỗi item
+
+#### 3. **[MaterialCostList.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/MaterialCostList.tsx)**
+- Danh sách chi phí nguyên liệu với chức năng thêm/xóa
+- Hiển thị: tên, số lượng, đơn vị, và giá (định dạng VNĐ)
+- Nút "Thêm nguyên liệu" với border dashed
+- Có nút xóa cho mỗi item
+
+#### 4. **[TripDurationPicker.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/TripDurationPicker.tsx)**
+- Chọn thời gian bắt đầu và kết thúc chuyến đi
+- Layout 2 cột (Bắt đầu | Kết thúc)
+- Sử dụng `DateTimePicker` với modal
+- Hiển thị ngày theo định dạng DD/MM/YYYY
+- Validation: Ngày kết thúc không thể trước ngày bắt đầu
+
+#### 5. **[PortSelector.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/PortSelector.tsx)**
+- Chọn cảng khởi hành và cảng cập bến
+- Layout 2 cột (Cảng khởi hành | Cảng cập bến)
+- Placeholder cho việc mở rộng: modal/dropdown chọn cảng trong tương lai
+- Hiện tại: Set giá trị dummy khi nhấn vào selector
+
+#### 6. **[BasicInfoInput.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/BasicInfoInput.tsx)**
+- Nhập thông tin cơ bản: Ổ khai thác (Initial Stock)
+- Input numeric với placeholder
+
+#### 7. **[AutoFillSection.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal/AutoFillSection.tsx)**
+- Tự động điền dữ liệu từ chuyến đi cuối cùng của tàu
+- Hiển thị ở đầu modal với UI card dashed border
+- Cho phép chọn tàu từ danh sách (có tìm kiếm)
+- Gọi API `GET /api/sgw/trips/last/{thingId}` để lấy dữ liệu chuyến đi cuối
+- Tự động fill các trường:
+ - Ship Selector (thingId của tàu)
+ - Tên chuyến đi
+ - Danh sách ngư cụ
+ - Chi phí nguyên liệu
+ - Cảng khởi hành / cập bến
+ - Ô ngư trường khai thác
+- Hiển thị Alert thông báo khi fill thành công
+
+## Data Structure
+
+### Form Data Interface
+```typescript
+interface TripFormData {
+ tripName: string;
+ fishingGears: FishingGear[];
+ materialCosts: MaterialCost[];
+ startDate: Date | null;
+ endDate: Date | null;
+ departurePort: string;
+ arrivalPort: string;
+ initialStock: string;
+}
+
+interface FishingGear {
+ id: string;
+ name: string;
+ quantity: number;
+}
+
+interface MaterialCost {
+ id: string;
+ name: string;
+ quantity: number;
+ unit: string;
+ price: number;
+}
+```
+
+## Integration with Diary Screen
+
+### Changes to [diary.tsx](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/app/(tabs)/diary.tsx)
+
+1. **Import AddTripModal:**
+```typescript
+import AddTripModal from "@/components/diary/addTripModal";
+```
+
+2. **Add State:**
+```typescript
+const [showAddTripModal, setShowAddTripModal] = useState(false);
+```
+
+3. **Update Button:**
+```typescript
+ setShowAddTripModal(true)}
+ activeOpacity={0.7}
+>
+
+ {t("diary.addTrip")}
+
+```
+
+4. **Add Modal Component:**
+```typescript
+ setShowAddTripModal(false)}
+/>
+```
+
+## Localization (i18n)
+
+### Added Translation Keys
+
+#### Vietnamese ([vi.json](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/locales/vi.json))
+```json
+{
+ "common": {
+ "done": "Xong"
+ },
+ "diary": {
+ "createTrip": "Tạo chuyến đi",
+ "tripNameLabel": "Tên chuyến đi",
+ "tripNamePlaceholder": "Nhập tên chuyến đi",
+ "fishingGearList": "Danh sách ngư cụ",
+ "addFishingGear": "Thêm ngư cụ",
+ "quantity": "Số lượng",
+ "materialCostList": "Chi phí nguyên liệu",
+ "addMaterialCost": "Thêm nguyên liệu",
+ "tripDuration": "Thời gian chuyến đi",
+ "startDate": "Bắt đầu",
+ "endDate": "Kết thúc",
+ "selectDate": "Chọn ngày",
+ "selectStartDate": "Chọn ngày bắt đầu",
+ "selectEndDate": "Chọn ngày kết thúc",
+ "portLabel": "Cảng",
+ "departurePort": "Cảng khởi hành",
+ "arrivalPort": "Cảng cập bến",
+ "selectPort": "Chọn cảng",
+ "basicInfo": "Thông tin cơ bản",
+ "initialStock": "Ổ khai thác",
+ "initialStockPlaceholder": "Nhập số ổ khai thác",
+ "autoFill": {
+ "title": "Tự động điền dữ liệu",
+ "description": "Điền từ chuyến đi cuối cùng của tàu",
+ "selectShip": "Chọn tàu",
+ "modalTitle": "Chọn tàu để lấy dữ liệu",
+ "loading": "Đang tải dữ liệu...",
+ "success": "Đã điền dữ liệu từ chuyến đi cuối cùng",
+ "error": "Không thể lấy dữ liệu chuyến đi",
+ "noData": "Không có dữ liệu chuyến đi trước đó"
+ }
+ }
+}
+```
+
+#### English ([en.json](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/locales/en.json))
+- All corresponding English translations added
+
+## Features
+
+### ✅ Implemented
+- [x] Modal với animation slide từ dưới lên
+- [x] Header với nút đóng và tiêu đề
+- [x] Scrollable content area
+- [x] Component tách biệt rõ ràng (8 components)
+- [x] Form validation cơ bản
+- [x] Theme support (light/dark mode)
+- [x] i18n support (Vietnamese/English)
+- [x] Console logging khi submit (thay vì API call)
+- [x] Reset form khi cancel/submit thành công
+- [x] Add/Remove fishing gears
+- [x] Add/Remove material costs
+- [x] Date pickers với validation
+- [x] Port selectors (placeholder cho future implementation)
+- [x] Basic info input
+- [x] **Auto-fill từ chuyến đi cuối cùng của tàu**
+ - Chọn tàu để lấy dữ liệu
+ - Gọi API GET /api/sgw/trips/last/{thingId}
+ - Tự động điền tất cả các trường dữ liệu
+ - Hiển thị Alert thông báo thành công
+
+### 🚧 Future Enhancements (TODO)
+- [ ] Modal chi tiết để thêm/edit fishing gear (hiện tại dùng dummy data)
+- [ ] Modal chi tiết để thêm/edit material cost (hiện tại dùng dummy data)
+- [ ] Dropdown/Modal chọn cảng thực tế
+- [ ] Form validation chi tiết (required fields, format validation)
+- [ ] API integration thay vì console.log
+- [ ] Loading state khi submit
+- [ ] Error handling và hiển thị thông báo
+- [ ] Success notification sau khi tạo
+- [ ] Refresh danh sách trips sau khi tạo mới
+
+## Testing
+
+### How to Test
+1. Chạy ứng dụng: `npx expo start`
+2. Mở tab "Nhật ký" (Diary)
+3. Nhấn nút "Thêm chuyến đi"
+4. Điền thông tin vào form
+5. Nhấn nút "Thêm ngư cụ" hoặc "Thêm nguyên liệu" để test add/remove
+6. Chọn ngày bắt đầu và kết thúc
+7. Nhấn "Tạo chuyến đi" để xem console log output
+
+### Expected Console Output
+```json
+=== Submitting Trip Data ===
+{
+ "tripName": "Chuyến đi mẫu",
+ "fishingGears": [
+ {
+ "id": "1733637655123",
+ "name": "Ngư cụ 1",
+ "quantity": 1
+ }
+ ],
+ "materialCosts": [
+ {
+ "id": "1733637660456",
+ "name": "Nguyên liệu 1",
+ "quantity": 1,
+ "unit": "kg",
+ "price": 0
+ }
+ ],
+ "startDate": "2024-12-08T04:00:00.000Z",
+ "endDate": "2024-12-15T04:00:00.000Z",
+ "departurePort": "Cảng Nha Trang",
+ "arrivalPort": "Cảng Quy Nhơn",
+ "initialStock": "10"
+}
+=== End Trip Data ===
+```
+
+## Code Quality
+
+- ✅ TypeScript types cho tất cả interfaces
+- ✅ Proper component separation
+- ✅ Theme-aware styling
+- ✅ Internationalization support
+- ✅ Clean code structure
+- ✅ Reusable components
+- ✅ No circular dependencies
+- ✅ Platform-specific styles (iOS/Android)
+
+## Summary
+
+Modal Add Trip đã được implement hoàn chỉnh với:
+- **7 components** tách biệt rõ ràng trong [addTripModal](file:///Users/nguyennhatminh/Documents/file%20code/Smatec/sgw-owner-app/components/diary/addTripModal) directory
+- **UI đầy đủ** theo design reference từ ảnh upload
+- **Chức năng log** dữ liệu thay vì API call (đúng yêu cầu)
+- **Theme support** cho dark/light mode
+- **i18n support** cho cả Tiếng Việt và English
+- **Ready for API integration** khi cần
+
+Tất cả code đã được tích hợp vào diary screen và sẵn sàng để test!
diff --git a/app/(tabs)/diary.tsx b/app/(tabs)/diary.tsx
index cb7bcd6..9e7de36 100644
--- a/app/(tabs)/diary.tsx
+++ b/app/(tabs)/diary.tsx
@@ -13,6 +13,7 @@ import { Ionicons } from "@expo/vector-icons";
import FilterButton from "@/components/diary/FilterButton";
import TripCard from "@/components/diary/TripCard";
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
+import AddTripModal from "@/components/diary/addTripModal";
import { useThings } from "@/state/use-thing";
import { useTripsList } from "@/state/use-tripslist";
import dayjs from "dayjs";
@@ -23,6 +24,7 @@ export default function diary() {
const { t } = useI18n();
const { colors } = useThemeContext();
const [showFilterModal, setShowFilterModal] = useState(false);
+ const [showAddTripModal, setShowAddTripModal] = useState(false);
const [filters, setFilters] = useState({
status: null,
startDate: null,
@@ -287,7 +289,7 @@ export default function diary() {
/>
console.log("Add trip")}
+ onPress={() => setShowAddTripModal(true)}
activeOpacity={0.7}
>
@@ -324,6 +326,12 @@ export default function diary() {
onClose={() => setShowFilterModal(false)}
onApply={handleApplyFilters}
/>
+
+ {/* Add Trip Modal */}
+ setShowAddTripModal(false)}
+ />
);
}
diff --git a/components/diary/TripCard.tsx b/components/diary/TripCard.tsx
index 392d17c..cd3b217 100644
--- a/components/diary/TripCard.tsx
+++ b/components/diary/TripCard.tsx
@@ -117,9 +117,9 @@ export default function TripCard({
{/* Info Grid */}
- {t("diary.tripCard.shipCode")}
+ {t("diary.tripCard.shipName")}
- {thingOfTrip?.metadata?.ship_reg_number /* hoặc trip.ship_id */}
+ {thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */}
diff --git a/components/diary/addTripModal/AutoFillSection.tsx b/components/diary/addTripModal/AutoFillSection.tsx
new file mode 100644
index 0000000..58c1b4d
--- /dev/null
+++ b/components/diary/addTripModal/AutoFillSection.tsx
@@ -0,0 +1,388 @@
+import React, { useState } from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+ Modal,
+ Platform,
+ ScrollView,
+ TextInput,
+ ActivityIndicator,
+ Alert,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { useThings } from "@/state/use-thing";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+import { queryLastTrip } from "@/controller/TripController";
+import { showErrorToast } from "@/services/toast_service";
+
+interface AutoFillSectionProps {
+ onAutoFill: (tripData: Model.Trip, selectedShipId: string) => void;
+}
+
+export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchText, setSearchText] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { things } = useThings();
+
+ // Convert things to ship options
+ const shipOptions =
+ 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 handleSelectShip = async (shipId: string) => {
+ setIsLoading(true);
+ try {
+ const response = await queryLastTrip(shipId);
+ if (response.data) {
+ // Close the modal first before showing alert
+ setIsOpen(false);
+ setSearchText("");
+
+ // Pass shipId (thingId) along with trip data for filling ShipSelector
+ onAutoFill(response.data, shipId);
+
+ // Use Alert instead of Toast so it appears above all modals
+ Alert.alert(
+ t("common.success"),
+ t("diary.autoFill.success")
+ );
+ } else {
+ showErrorToast(t("diary.autoFill.noData"));
+ }
+ } catch (error) {
+ console.error("Error fetching last trip:", error);
+ showErrorToast(t("diary.autoFill.error"));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const themedStyles = {
+ container: {
+ backgroundColor: colors.backgroundSecondary,
+ borderColor: colors.primary,
+ },
+ label: { color: colors.text },
+ description: { color: colors.textSecondary },
+ button: {
+ backgroundColor: colors.primary,
+ },
+ modalContent: { backgroundColor: colors.card },
+ searchContainer: {
+ backgroundColor: colors.backgroundSecondary,
+ borderColor: colors.border,
+ },
+ searchInput: { color: colors.text },
+ option: { borderBottomColor: colors.separator },
+ optionText: { color: colors.text },
+ emptyText: { color: colors.textSecondary },
+ };
+
+ return (
+
+
+
+
+
+
+
+ {t("diary.autoFill.title")}
+
+
+ {t("diary.autoFill.description")}
+
+
+
+ setIsOpen(true)}
+ activeOpacity={0.7}
+ >
+ {t("diary.autoFill.selectShip")}
+
+
+ setIsOpen(false)}
+ >
+ setIsOpen(false)}
+ >
+ true}
+ >
+ {/* Header */}
+
+
+ {t("diary.autoFill.modalTitle")}
+
+
+
+ {/* Search Input */}
+
+
+
+ {searchText.length > 0 && (
+ setSearchText("")}>
+
+
+ )}
+
+
+ {isLoading ? (
+
+
+
+ {t("diary.autoFill.loading")}
+
+
+ ) : (
+
+ {/* Filtered ship options */}
+ {filteredShips.length > 0 ? (
+ filteredShips.map((ship) => (
+ handleSelectShip(ship.id)}
+ >
+
+
+
+ {ship.shipName}
+
+
+
+
+ ))
+ ) : (
+
+
+ {t("diary.noShipsFound")}
+
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderStyle: "dashed",
+ },
+ contentWrapper: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 12,
+ },
+ iconContainer: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: "rgba(59, 130, 246, 0.1)",
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: 12,
+ },
+ textContainer: {
+ flex: 1,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 4,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ description: {
+ fontSize: 13,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ button: {
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ alignItems: "center",
+ },
+ buttonText: {
+ fontSize: 14,
+ fontWeight: "600",
+ color: "#FFFFFF",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalContent: {
+ borderRadius: 12,
+ width: "85%",
+ maxHeight: "70%",
+ overflow: "hidden",
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 4,
+ },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ modalHeader: {
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ },
+ modalTitle: {
+ fontSize: 18,
+ fontWeight: "700",
+ textAlign: "center",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ searchContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ },
+ searchIcon: {
+ marginRight: 8,
+ },
+ searchInput: {
+ flex: 1,
+ fontSize: 16,
+ 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,
+ },
+ optionContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ flex: 1,
+ },
+ optionIcon: {
+ marginRight: 12,
+ },
+ optionText: {
+ fontSize: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ emptyContainer: {
+ paddingVertical: 24,
+ alignItems: "center",
+ },
+ emptyText: {
+ fontSize: 14,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ loadingContainer: {
+ paddingVertical: 40,
+ alignItems: "center",
+ },
+ loadingText: {
+ marginTop: 12,
+ fontSize: 14,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/BasicInfoInput.tsx b/components/diary/addTripModal/BasicInfoInput.tsx
new file mode 100644
index 0000000..76f71ae
--- /dev/null
+++ b/components/diary/addTripModal/BasicInfoInput.tsx
@@ -0,0 +1,89 @@
+import React from "react";
+import {
+ View,
+ Text,
+ TextInput,
+ StyleSheet,
+ Platform,
+} from "react-native";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface BasicInfoInputProps {
+ fishingGroundCodes: string;
+ onChange: (value: string) => void;
+}
+
+export default function BasicInfoInput({
+ fishingGroundCodes,
+ onChange,
+}: BasicInfoInputProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+
+ const themedStyles = {
+ label: { color: colors.text },
+ subLabel: { color: colors.textSecondary },
+ input: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ color: colors.text,
+ },
+ };
+
+ return (
+
+
+ {t("diary.fishingGroundCodes")}
+
+
+ {t("diary.fishingGroundCodesHint")}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 12,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ subLabel: {
+ fontSize: 14,
+ marginBottom: 8,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ fontSize: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/FishingGearList.tsx b/components/diary/addTripModal/FishingGearList.tsx
new file mode 100644
index 0000000..ed69389
--- /dev/null
+++ b/components/diary/addTripModal/FishingGearList.tsx
@@ -0,0 +1,225 @@
+import React from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+ TextInput,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface FishingGear {
+ id: string;
+ name: string;
+ number: string;
+}
+
+interface FishingGearListProps {
+ items: FishingGear[];
+ onChange: (items: FishingGear[]) => void;
+}
+
+export default function FishingGearList({
+ items,
+ onChange,
+}: FishingGearListProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+
+ const handleAddGear = () => {
+ const newGear: FishingGear = {
+ id: Date.now().toString(),
+ name: "",
+ number: "",
+ };
+ onChange([...items, newGear]);
+ };
+
+ const handleRemoveGear = (id: string) => {
+ onChange(items.filter((item) => item.id !== id));
+ };
+
+ const handleDuplicateGear = (gear: FishingGear) => {
+ const duplicatedGear: FishingGear = {
+ id: Date.now().toString(),
+ name: gear.name,
+ number: gear.number,
+ };
+ onChange([...items, duplicatedGear]);
+ };
+
+ const handleUpdateGear = (id: string, field: keyof FishingGear, value: string) => {
+ onChange(
+ items.map((item) =>
+ item.id === id ? { ...item, [field]: value } : item
+ )
+ );
+ };
+
+ const themedStyles = {
+ sectionTitle: { color: colors.text },
+ fieldLabel: { color: colors.text },
+ input: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ color: colors.text,
+ },
+ addButton: {
+ borderColor: colors.primary,
+ },
+ addButtonText: { color: colors.primary },
+ };
+
+ return (
+
+
+ {t("diary.fishingGearList")}
+
+
+ {/* Gear Items List */}
+ {items.map((gear, index) => (
+
+ {/* Name Input */}
+
+
+ {t("diary.gearName")}
+
+ handleUpdateGear(gear.id, "name", value)}
+ placeholder={t("diary.gearNamePlaceholder")}
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+ {/* Number Input */}
+
+
+ {t("diary.gearNumber")}
+
+ handleUpdateGear(gear.id, "number", value)}
+ placeholder={t("diary.gearNumberPlaceholder")}
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="numeric"
+ />
+
+
+ {/* Action Buttons */}
+
+ handleDuplicateGear(gear)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ handleRemoveGear(gear.id)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+
+
+ ))}
+
+ {/* Add Button */}
+
+
+
+ {t("diary.addFishingGear")}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: "700",
+ marginBottom: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ gearRow: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ gap: 12,
+ marginBottom: 16,
+ },
+ inputGroup: {
+ flex: 1,
+ },
+ fieldLabel: {
+ fontSize: 14,
+ fontWeight: "500",
+ marginBottom: 6,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 15,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ nameInput: {
+ flex: 1,
+ },
+ numberInput: {
+ flex: 1,
+ },
+ actionButtons: {
+ flexDirection: "row",
+ gap: 12,
+ alignItems: "center",
+ paddingBottom: 10,
+ },
+ addButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderWidth: 1.5,
+ borderRadius: 8,
+ borderStyle: "dashed",
+ marginTop: 4,
+ },
+ addButtonText: {
+ fontSize: 14,
+ fontWeight: "600",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/MaterialCostList.tsx b/components/diary/addTripModal/MaterialCostList.tsx
new file mode 100644
index 0000000..d424c6b
--- /dev/null
+++ b/components/diary/addTripModal/MaterialCostList.tsx
@@ -0,0 +1,472 @@
+import React, { useState } from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+ TextInput,
+ Modal,
+ ScrollView,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface TripCost {
+ id: string;
+ type: string;
+ amount: number;
+ unit: string;
+ cost_per_unit: number;
+ total_cost: number;
+}
+
+interface MaterialCostListProps {
+ items: TripCost[];
+ onChange: (items: TripCost[]) => void;
+}
+
+// Predefined cost types
+const COST_TYPES = [
+ { value: "fuel", label: "Nhiên liệu" },
+ { value: "food", label: "Thực phẩm" },
+ { value: "crew_salary", label: "Lương thuyền viên" },
+ { value: "ice_salt_cost", label: "Muối đá" },
+ { value: "other", label: "Khác" },
+];
+
+export default function MaterialCostList({
+ items,
+ onChange,
+}: MaterialCostListProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+ const [typeDropdownVisible, setTypeDropdownVisible] = useState(null);
+
+ const handleAddMaterial = () => {
+ const newMaterial: TripCost = {
+ id: Date.now().toString(),
+ type: "",
+ amount: 0,
+ unit: "",
+ cost_per_unit: 0,
+ total_cost: 0,
+ };
+ onChange([...items, newMaterial]);
+ };
+
+ const handleRemoveMaterial = (id: string) => {
+ onChange(items.filter((item) => item.id !== id));
+ };
+
+ const handleDuplicateMaterial = (material: TripCost) => {
+ const duplicatedMaterial: TripCost = {
+ id: Date.now().toString(),
+ type: material.type,
+ amount: material.amount,
+ unit: material.unit,
+ cost_per_unit: material.cost_per_unit,
+ total_cost: material.total_cost,
+ };
+ onChange([...items, duplicatedMaterial]);
+ };
+
+ const handleUpdateMaterial = (id: string, field: keyof TripCost, value: string | number) => {
+ onChange(
+ items.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, [field]: value };
+ // Auto-calculate total_cost when amount or cost_per_unit changes
+ if (field === "amount" || field === "cost_per_unit") {
+ const amount = field === "amount" ? Number(value) : item.amount;
+ const costPerUnit = field === "cost_per_unit" ? Number(value) : item.cost_per_unit;
+ updatedItem.total_cost = amount * costPerUnit;
+ }
+ return updatedItem;
+ }
+ return item;
+ })
+ );
+ };
+
+ const getTypeLabel = (value: string) => {
+ const type = COST_TYPES.find((t) => t.value === value);
+ return type ? type.label : value || t("diary.selectType");
+ };
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat("vi-VN").format(amount);
+ };
+
+ const themedStyles = {
+ sectionTitle: { color: colors.text },
+ fieldLabel: { color: colors.text },
+ input: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ color: colors.text,
+ },
+ dropdown: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ },
+ dropdownText: { color: colors.text },
+ placeholder: { color: colors.textSecondary },
+ addButton: {
+ borderColor: colors.primary,
+ },
+ addButtonText: { color: colors.primary },
+ modalOverlay: {
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ },
+ modalContent: {
+ backgroundColor: colors.card,
+ },
+ option: {
+ borderBottomColor: colors.separator,
+ },
+ optionText: { color: colors.text },
+ };
+
+ return (
+
+
+ {t("diary.materialCostList")}
+
+
+ {/* Cost Items List */}
+ {items.map((cost) => (
+
+
+ {/* Type Dropdown */}
+
+
+ {t("diary.costType")}
+
+ setTypeDropdownVisible(cost.id)}
+ activeOpacity={0.7}
+ >
+
+ {getTypeLabel(cost.type)}
+
+
+
+
+ {/* Type Dropdown Modal */}
+ setTypeDropdownVisible(null)}
+ >
+ setTypeDropdownVisible(null)}
+ >
+
+
+ {COST_TYPES.map((type) => (
+ {
+ handleUpdateMaterial(cost.id, "type", type.value);
+ setTypeDropdownVisible(null);
+ }}
+ >
+
+ {type.label}
+
+ {cost.type === type.value && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ {/* Amount Input */}
+
+
+ {t("diary.amount")}
+
+
+ handleUpdateMaterial(cost.id, "amount", Number(value) || 0)
+ }
+ placeholder="0"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="numeric"
+ />
+
+
+ {/* Unit Input */}
+
+
+ {t("diary.unit")}
+
+
+ handleUpdateMaterial(cost.id, "unit", value)
+ }
+ placeholder={t("diary.unitPlaceholder")}
+ placeholderTextColor={colors.textSecondary}
+ />
+
+
+
+
+ {/* Cost Per Unit Input */}
+
+
+ {t("diary.costPerUnit")}
+
+
+ handleUpdateMaterial(
+ cost.id,
+ "cost_per_unit",
+ Number(value) || 0
+ )
+ }
+ placeholder="0"
+ placeholderTextColor={colors.textSecondary}
+ keyboardType="numeric"
+ />
+
+
+ {/* Total Cost (Read-only, auto-calculated) */}
+
+
+ {t("diary.totalCost")}
+
+
+
+ {formatCurrency(cost.total_cost)}
+
+
+
+
+ {/* Action Buttons */}
+
+ handleDuplicateMaterial(cost)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+ handleRemoveMaterial(cost.id)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ >
+
+
+
+
+
+ ))}
+
+ {/* Add Button */}
+
+
+
+ {t("diary.addMaterialCost")}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: "700",
+ marginBottom: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ costRow: {
+ marginBottom: 20,
+ paddingBottom: 16,
+ borderBottomWidth: 1,
+ borderBottomColor: "#E5E5E5",
+ },
+ formRow: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ gap: 12,
+ marginBottom: 12,
+ },
+ inputGroup: {
+ flex: 1,
+ },
+ typeGroup: {
+ flex: 1.5,
+ },
+ smallGroup: {
+ flex: 0.8,
+ },
+ mediumGroup: {
+ flex: 1,
+ },
+ fieldLabel: {
+ fontSize: 14,
+ fontWeight: "500",
+ marginBottom: 6,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 15,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ smallInput: {},
+ mediumInput: {},
+ dropdown: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ },
+ dropdownText: {
+ fontSize: 15,
+ flex: 1,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ readOnlyInput: {
+ justifyContent: "center",
+ opacity: 0.7,
+ },
+ readOnlyText: {
+ fontSize: 15,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ actionButtons: {
+ flexDirection: "row",
+ gap: 12,
+ alignItems: "center",
+ paddingBottom: 10,
+ },
+ addButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ borderWidth: 1.5,
+ borderRadius: 8,
+ borderStyle: "dashed",
+ marginTop: 4,
+ },
+ addButtonText: {
+ fontSize: 14,
+ fontWeight: "600",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ // Modal styles
+ modalOverlay: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalContent: {
+ borderRadius: 12,
+ width: "80%",
+ maxHeight: "50%",
+ overflow: "hidden",
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: 4,
+ },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ 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",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/PortSelector.tsx b/components/diary/addTripModal/PortSelector.tsx
new file mode 100644
index 0000000..e301fce
--- /dev/null
+++ b/components/diary/addTripModal/PortSelector.tsx
@@ -0,0 +1,171 @@
+import React from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface PortSelectorProps {
+ departurePortId: number;
+ arrivalPortId: number;
+ onDeparturePortChange: (portId: number) => void;
+ onArrivalPortChange: (portId: number) => void;
+}
+
+export default function PortSelector({
+ departurePortId,
+ arrivalPortId,
+ onDeparturePortChange,
+ onArrivalPortChange,
+}: PortSelectorProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+
+ const handleSelectDeparturePort = () => {
+ console.log("Select departure port pressed");
+ // TODO: Implement port selection modal/dropdown
+ // For now, just set a dummy ID
+ onDeparturePortChange(1);
+ };
+
+ const handleSelectArrivalPort = () => {
+ console.log("Select arrival port pressed");
+ // TODO: Implement port selection modal/dropdown
+ // For now, just set a dummy ID
+ onArrivalPortChange(1);
+ };
+
+ // Helper to display port name (in production, fetch from port list 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 themedStyles = {
+ label: { color: colors.text },
+ portSelector: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ },
+ portText: { color: colors.text },
+ placeholder: { color: colors.textSecondary },
+ };
+
+ return (
+
+
+ {t("diary.portLabel")}
+
+
+ {/* Departure Port */}
+
+
+ {t("diary.departurePort")}
+
+
+
+ {getPortDisplayName(departurePortId)}
+
+
+
+
+
+ {/* Arrival Port */}
+
+
+ {t("diary.arrivalPort")}
+
+
+
+ {getPortDisplayName(arrivalPortId)}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 12,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ subLabel: {
+ fontSize: 14,
+ marginBottom: 6,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ portContainer: {
+ flexDirection: "row",
+ gap: 12,
+ },
+ portSection: {
+ flex: 1,
+ },
+ portSelector: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ },
+ portText: {
+ fontSize: 15,
+ flex: 1,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/ShipSelector.tsx b/components/diary/addTripModal/ShipSelector.tsx
new file mode 100644
index 0000000..d03f8a3
--- /dev/null
+++ b/components/diary/addTripModal/ShipSelector.tsx
@@ -0,0 +1,293 @@
+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";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface ShipSelectorProps {
+ selectedShipId: string;
+ onChange: (shipId: string) => void;
+}
+
+export default function ShipSelector({
+ selectedShipId,
+ onChange,
+}: ShipSelectorProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchText, setSearchText] = useState("");
+
+ const { things } = useThings();
+
+ // Convert things to ship options
+ const shipOptions =
+ 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 = (shipId: string) => {
+ onChange(shipId);
+ setIsOpen(false);
+ setSearchText("");
+ };
+
+ const selectedShip = shipOptions.find((ship) => ship.id === selectedShipId);
+ const displayValue = selectedShip
+ ? selectedShip.shipName
+ : t("diary.selectShip");
+
+ const themedStyles = {
+ label: { color: colors.text },
+ selector: { backgroundColor: colors.card, borderColor: colors.border },
+ selectorText: { color: colors.text },
+ placeholder: { color: colors.textSecondary },
+ modalContent: { backgroundColor: colors.card },
+ searchContainer: {
+ backgroundColor: colors.backgroundSecondary,
+ borderColor: colors.border,
+ },
+ searchInput: { color: colors.text },
+ option: { borderBottomColor: colors.separator },
+ selectedOption: { backgroundColor: colors.backgroundSecondary },
+ optionText: { color: colors.text },
+ emptyText: { color: colors.textSecondary },
+ };
+
+ return (
+
+
+ {t("diary.shipSelector")}
+ *
+
+ setIsOpen(true)}
+ activeOpacity={0.7}
+ >
+
+ {displayValue}
+
+
+
+
+ setIsOpen(false)}
+ >
+ setIsOpen(false)}
+ >
+ true}
+ >
+ {/* Search Input */}
+
+
+
+ {searchText.length > 0 && (
+ setSearchText("")}>
+
+
+ )}
+
+
+
+ {/* Filtered ship options */}
+ {filteredShips.length > 0 ? (
+ filteredShips.map((ship) => (
+ handleSelect(ship.id)}
+ >
+
+ {ship.shipName}
+
+ {selectedShipId === ship.id && (
+
+ )}
+
+ ))
+ ) : (
+
+
+ {t("diary.noShipsFound")}
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 8,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ required: {
+ color: "#EF4444",
+ },
+ selector: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ },
+ selectorText: {
+ fontSize: 16,
+ flex: 1,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalContent: {
+ 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,
+ },
+ searchIcon: {
+ marginRight: 8,
+ },
+ searchInput: {
+ flex: 1,
+ fontSize: 16,
+ 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,
+ },
+ optionText: {
+ fontSize: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ emptyContainer: {
+ paddingVertical: 24,
+ alignItems: "center",
+ },
+ emptyText: {
+ fontSize: 14,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/TripDurationPicker.tsx b/components/diary/addTripModal/TripDurationPicker.tsx
new file mode 100644
index 0000000..d526d3a
--- /dev/null
+++ b/components/diary/addTripModal/TripDurationPicker.tsx
@@ -0,0 +1,289 @@
+import React, { useState } from "react";
+import {
+ View,
+ Text,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+ Modal,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import DateTimePicker from "@react-native-community/datetimepicker";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface TripDurationPickerProps {
+ startDate: Date | null;
+ endDate: Date | null;
+ onStartDateChange: (date: Date | null) => void;
+ onEndDateChange: (date: Date | null) => void;
+}
+
+export default function TripDurationPicker({
+ startDate,
+ endDate,
+ onStartDateChange,
+ onEndDateChange,
+}: TripDurationPickerProps) {
+ const { t } = useI18n();
+ const { colors, colorScheme } = useThemeContext();
+ const [showStartPicker, setShowStartPicker] = useState(false);
+ const [showEndPicker, setShowEndPicker] = useState(false);
+
+ const formatDate = (date: Date | null) => {
+ if (!date) return "";
+ const day = date.getDate().toString().padStart(2, "0");
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
+ const year = date.getFullYear();
+ return `${day}/${month}/${year}`;
+ };
+
+ const handleStartDateChange = (event: any, selectedDate?: Date) => {
+ setShowStartPicker(Platform.OS === "ios");
+ if (selectedDate) {
+ onStartDateChange(selectedDate);
+ }
+ };
+
+ const handleEndDateChange = (event: any, selectedDate?: Date) => {
+ setShowEndPicker(Platform.OS === "ios");
+ if (selectedDate) {
+ onEndDateChange(selectedDate);
+ }
+ };
+
+ const themedStyles = {
+ label: { color: colors.text },
+ dateInput: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ },
+ dateText: { color: colors.text },
+ placeholder: { color: colors.textSecondary },
+ pickerContainer: { backgroundColor: colors.card },
+ pickerHeader: { borderBottomColor: colors.border },
+ pickerTitle: { color: colors.text },
+ cancelButton: { color: colors.textSecondary },
+ };
+
+ return (
+
+
+ {t("diary.tripDuration")}
+
+
+ {/* Start Date */}
+
+
+ {t("diary.startDate")}
+
+ setShowStartPicker(true)}
+ activeOpacity={0.7}
+ >
+
+ {startDate ? formatDate(startDate) : t("diary.selectDate")}
+
+
+
+
+
+ {/* End Date */}
+
+
+ {t("diary.endDate")}
+
+ setShowEndPicker(true)}
+ activeOpacity={0.7}
+ >
+
+ {endDate ? formatDate(endDate) : t("diary.selectDate")}
+
+
+
+
+
+
+ {/* Start Date Picker */}
+ {showStartPicker && (
+
+
+
+
+ setShowStartPicker(false)}>
+
+ {t("common.cancel")}
+
+
+
+ {t("diary.selectStartDate")}
+
+ setShowStartPicker(false)}>
+ {t("common.done")}
+
+
+
+
+
+
+ )}
+
+ {/* End Date Picker */}
+ {showEndPicker && (
+
+
+
+
+ setShowEndPicker(false)}>
+
+ {t("common.cancel")}
+
+
+
+ {t("diary.selectEndDate")}
+
+ setShowEndPicker(false)}>
+ {t("common.done")}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 12,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ subLabel: {
+ fontSize: 14,
+ marginBottom: 6,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ dateRangeContainer: {
+ flexDirection: "row",
+ gap: 12,
+ },
+ dateSection: {
+ flex: 1,
+ },
+ dateInput: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ },
+ dateText: {
+ fontSize: 15,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ pickerContainer: {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ paddingBottom: 20,
+ },
+ pickerHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ borderBottomWidth: 1,
+ },
+ pickerTitle: {
+ fontSize: 16,
+ fontWeight: "600",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ cancelButton: {
+ fontSize: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ doneButton: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#3B82F6",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/TripNameInput.tsx b/components/diary/addTripModal/TripNameInput.tsx
new file mode 100644
index 0000000..0b8d63e
--- /dev/null
+++ b/components/diary/addTripModal/TripNameInput.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import {
+ View,
+ Text,
+ TextInput,
+ StyleSheet,
+ Platform,
+} from "react-native";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+
+interface TripNameInputProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export default function TripNameInput({ value, onChange }: TripNameInputProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+
+ const themedStyles = {
+ label: { color: colors.text },
+ input: {
+ backgroundColor: colors.card,
+ borderColor: colors.border,
+ color: colors.text,
+ },
+ };
+
+ return (
+
+
+ {t("diary.tripNameLabel")}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ marginBottom: 8,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ fontSize: 16,
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/components/diary/addTripModal/index.tsx b/components/diary/addTripModal/index.tsx
new file mode 100644
index 0000000..06b83f8
--- /dev/null
+++ b/components/diary/addTripModal/index.tsx
@@ -0,0 +1,386 @@
+import React, { useState } from "react";
+import {
+ View,
+ Text,
+ Modal,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+ ScrollView,
+} from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import { useI18n } from "@/hooks/use-i18n";
+import { useThemeContext } from "@/hooks/use-theme-context";
+import FishingGearList from "@/components/diary/addTripModal/FishingGearList";
+import MaterialCostList from "@/components/diary/addTripModal/MaterialCostList";
+import TripNameInput from "@/components/diary/addTripModal/TripNameInput";
+import TripDurationPicker from "@/components/diary/addTripModal/TripDurationPicker";
+import PortSelector from "@/components/diary/addTripModal/PortSelector";
+import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput";
+import ShipSelector from "./ShipSelector";
+import AutoFillSection from "./AutoFillSection";
+
+
+// Internal component interfaces
+export interface FishingGear {
+ id: string;
+ name: string;
+ number: string; // Changed from quantity to number (string)
+}
+
+export interface TripCost {
+ id: string;
+ type: string;
+ amount: number;
+ unit: string;
+ cost_per_unit: number;
+ total_cost: number;
+}
+
+// API body interface
+export interface TripAPIBody {
+ thing_id?: string; // Ship ID
+ name: string;
+ departure_time: string; // ISO string
+ departure_port_id: number;
+ arrival_time: string; // ISO string
+ arrival_port_id: number;
+ fishing_ground_codes: number[]; // Array of numbers
+ fishing_gears: Array<{
+ name: string;
+ number: string;
+ }>;
+ trip_cost: Array<{
+ type: string;
+ amount: number;
+ unit: string;
+ cost_per_unit: number;
+ total_cost: number;
+ }>;
+}
+
+interface AddTripModalProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
+ const { t } = useI18n();
+ const { colors } = useThemeContext();
+
+ // Form state
+ const [selectedShipId, setSelectedShipId] = useState("");
+ const [tripName, setTripName] = useState("");
+ const [fishingGears, setFishingGears] = useState([]);
+ const [tripCosts, setTripCosts] = useState([]);
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [departurePortId, setDeparturePortId] = useState(1);
+ const [arrivalPortId, setArrivalPortId] = useState(1);
+ const [fishingGroundCodes, setFishingGroundCodes] = useState(""); // Input as string, convert to array
+
+ const handleCancel = () => {
+ // Reset form
+ setSelectedShipId("");
+ setTripName("");
+ setFishingGears([]);
+ setTripCosts([]);
+ setStartDate(null);
+ setEndDate(null);
+ setDeparturePortId(1);
+ setArrivalPortId(1);
+ setFishingGroundCodes("");
+ onClose();
+ };
+
+ // Handle auto-fill from last trip data
+ const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
+ // Fill ship ID (use the thingId from the selected ship for ShipSelector)
+ setSelectedShipId(selectedThingId);
+
+ // Fill trip name
+ if (tripData.name) {
+ setTripName(tripData.name);
+ }
+
+ // Fill fishing gears
+ if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
+ const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
+ id: `auto-${Date.now()}-${index}`,
+ name: gear.name || "",
+ number: gear.number?.toString() || "",
+ }));
+ setFishingGears(gears);
+ }
+
+ // Fill trip costs
+ if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
+ const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
+ id: `auto-${Date.now()}-${index}`,
+ type: cost.type || "",
+ amount: cost.amount || 0,
+ unit: cost.unit || "",
+ cost_per_unit: cost.cost_per_unit || 0,
+ total_cost: cost.total_cost || 0,
+ }));
+ setTripCosts(costs);
+ }
+
+ // Fill departure and arrival ports
+ if (tripData.departure_port_id) {
+ setDeparturePortId(tripData.departure_port_id);
+ }
+ if (tripData.arrival_port_id) {
+ setArrivalPortId(tripData.arrival_port_id);
+ }
+
+ // Fill fishing ground codes
+ if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
+ setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
+ }
+ };
+
+ const handleSubmit = () => {
+ // Parse fishing ground codes from comma-separated string to array of numbers
+ const fishingGroundCodesArray = fishingGroundCodes
+ .split(",")
+ .map((code) => parseInt(code.trim()))
+ .filter((code) => !isNaN(code));
+
+ // Format API body
+ const apiBody: TripAPIBody = {
+ thing_id: selectedShipId || undefined,
+ name: tripName,
+ departure_time: startDate ? startDate.toISOString() : "",
+ departure_port_id: departurePortId,
+ arrival_time: endDate ? endDate.toISOString() : "",
+ arrival_port_id: arrivalPortId,
+ fishing_ground_codes: fishingGroundCodesArray,
+ fishing_gears: fishingGears.map((gear) => ({
+ name: gear.name,
+ number: gear.number,
+ })),
+ trip_cost: tripCosts.map((cost) => ({
+ type: cost.type,
+ amount: cost.amount,
+ unit: cost.unit,
+ cost_per_unit: cost.cost_per_unit,
+ total_cost: cost.total_cost,
+ })),
+ };
+
+ // Simulate API call - log the formatted data
+ console.log("=== Submitting Trip Data (API Format) ===");
+ console.log(JSON.stringify(apiBody, null, 2));
+ console.log("=== End Trip Data ===");
+
+ // Reset form and close modal
+ handleCancel();
+ };
+
+ const themedStyles = {
+ modalContainer: {
+ backgroundColor: colors.card,
+ },
+ header: {
+ borderBottomColor: colors.separator,
+ },
+ title: {
+ color: colors.text,
+ },
+ footer: {
+ borderTopColor: colors.separator,
+ },
+ cancelButton: {
+ backgroundColor: colors.backgroundSecondary,
+ },
+ cancelButtonText: {
+ color: colors.textSecondary,
+ },
+ submitButton: {
+ backgroundColor: colors.primary,
+ },
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+ {t("diary.addTrip")}
+
+
+
+
+ {/* Content */}
+
+ {/* Auto Fill Section */}
+
+
+ {/* Ship Selector */}
+
+
+ {/* Trip Name */}
+
+
+ {/* Fishing Gear List */}
+
+
+ {/* Trip Cost List */}
+
+
+ {/* Trip Duration */}
+
+
+ {/* Port Selector */}
+
+
+ {/* Fishing Ground Codes */}
+
+
+
+ {/* Footer */}
+
+
+
+ {t("common.cancel")}
+
+
+
+
+ {t("diary.createTrip")}
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "flex-end",
+ },
+ modalContainer: {
+ borderTopLeftRadius: 24,
+ borderTopRightRadius: 24,
+ maxHeight: "90%",
+ shadowColor: "#000",
+ shadowOffset: {
+ width: 0,
+ height: -4,
+ },
+ shadowOpacity: 0.1,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ borderBottomWidth: 1,
+ },
+ closeButton: {
+ padding: 4,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: "700",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ placeholder: {
+ width: 32,
+ },
+ content: {
+ padding: 20,
+ },
+ footer: {
+ flexDirection: "row",
+ gap: 12,
+ padding: 20,
+ borderTopWidth: 1,
+ },
+ cancelButton: {
+ flex: 1,
+ paddingVertical: 14,
+ borderRadius: 12,
+ alignItems: "center",
+ },
+ cancelButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+ submitButton: {
+ flex: 1,
+ paddingVertical: 14,
+ borderRadius: 12,
+ alignItems: "center",
+ },
+ submitButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: "#FFFFFF",
+ fontFamily: Platform.select({
+ ios: "System",
+ android: "Roboto",
+ default: "System",
+ }),
+ },
+});
diff --git a/constants/index.ts b/constants/index.ts
index d17b0ec..a6ef37d 100644
--- a/constants/index.ts
+++ b/constants/index.ts
@@ -54,3 +54,4 @@ export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";
+export const API_GET_LAST_TRIP = "/api/sgw/trips/last";
diff --git a/controller/TripController.ts b/controller/TripController.ts
index d3cd090..ba89daf 100644
--- a/controller/TripController.ts
+++ b/controller/TripController.ts
@@ -5,12 +5,17 @@ import {
API_POST_TRIPSLIST,
API_UPDATE_FISHING_LOGS,
API_UPDATE_TRIP_STATUS,
+ API_GET_LAST_TRIP,
} from "@/constants";
export async function queryTrip() {
return api.get(API_GET_TRIP);
}
+export async function queryLastTrip(thingId: string) {
+ return api.get(`${API_GET_LAST_TRIP}/${thingId}`);
+}
+
export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
return api.put(API_UPDATE_TRIP_STATUS, body);
}
diff --git a/locales/en.json b/locales/en.json
index f990cc1..3b0f9c3 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -4,6 +4,7 @@
"footer_text": "Product of Mobifone v1.0",
"ok": "OK",
"cancel": "Cancel",
+ "done": "Done",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
@@ -102,6 +103,7 @@
},
"tripCard": {
"shipCode": "Ship Code",
+ "shipName": "Ship Name",
"departure": "Departure",
"return": "Return",
"view": "View",
@@ -117,6 +119,52 @@
"departed": "Departed",
"completed": "Completed",
"cancelled": "Cancelled"
+ },
+ "createTrip": "Create Trip",
+ "shipSelector": "Select Ship",
+ "selectShip": "Select ship",
+ "searchShip": "Search ship...",
+ "noShipsFound": "No ships found",
+ "tripNameLabel": "Trip Name",
+ "tripNamePlaceholder": "Enter trip name",
+ "fishingGearList": "Fishing Gear List",
+ "addFishingGear": "Add Fishing Gear",
+ "gearName": "Name",
+ "gearNamePlaceholder": "Name",
+ "gearNumber": "Quantity",
+ "gearNumberPlaceholder": "Quantity",
+ "quantity": "Quantity",
+ "materialCostList": "Material Costs",
+ "addMaterialCost": "Add Material",
+ "costType": "Type",
+ "selectType": "Select type",
+ "amount": "Amount",
+ "unit": "Unit",
+ "unitPlaceholder": "Unit",
+ "costPerUnit": "Cost",
+ "totalCost": "Total Cost",
+ "tripDuration": "Trip Duration",
+ "startDate": "Start",
+ "endDate": "End",
+ "selectDate": "Select Date",
+ "selectStartDate": "Select start date",
+ "selectEndDate": "Select end date",
+ "portLabel": "Port",
+ "departurePort": "Departure Port",
+ "arrivalPort": "Arrival Port",
+ "selectPort": "Select port",
+ "fishingGroundCodes": "Fishing Ground Codes",
+ "fishingGroundCodesHint": "Enter fishing ground codes (comma separated)",
+ "fishingGroundCodesPlaceholder": "e.g: 1,2,3",
+ "autoFill": {
+ "title": "Auto-fill data",
+ "description": "Fill from the ship's last trip",
+ "selectShip": "Select ship",
+ "modalTitle": "Select ship to get data",
+ "loading": "Loading data...",
+ "success": "Data filled from last trip",
+ "error": "Unable to fetch trip data",
+ "noData": "No previous trip data available"
}
},
"trip": {
diff --git a/locales/vi.json b/locales/vi.json
index 389f7ef..cc0c329 100644
--- a/locales/vi.json
+++ b/locales/vi.json
@@ -4,6 +4,7 @@
"footer_text": "Sản phẩm của Mobifone v1.0",
"ok": "OK",
"cancel": "Hủy",
+ "done": "Xong",
"save": "Lưu",
"delete": "Xóa",
"edit": "Chỉnh sửa",
@@ -102,6 +103,7 @@
},
"tripCard": {
"shipCode": "Mã Tàu",
+ "shipName": "Tên Tàu",
"departure": "Khởi hành",
"return": "Trở về",
"view": "Xem",
@@ -117,6 +119,52 @@
"departed": "Đã xuất bến",
"completed": "Đã hoàn thành",
"cancelled": "Đã huỷ"
+ },
+ "createTrip": "Tạo chuyến đi",
+ "shipSelector": "Chọn tàu",
+ "selectShip": "Chọn tàu",
+ "searchShip": "Tìm kiếm tàu...",
+ "noShipsFound": "Không tìm thấy tàu phù hợp",
+ "tripNameLabel": "Tên chuyến đi",
+ "tripNamePlaceholder": "Nhập tên chuyến đi",
+ "fishingGearList": "Danh sách ngư cụ",
+ "addFishingGear": "Thêm ngư cụ",
+ "gearName": "Tên",
+ "gearNamePlaceholder": "Tên",
+ "gearNumber": "Số lượng",
+ "gearNumberPlaceholder": "Số lượng",
+ "quantity": "Số lượng",
+ "materialCostList": "Chi phí nguyên liệu",
+ "addMaterialCost": "Thêm nguyên liệu",
+ "costType": "Loại",
+ "selectType": "Chọn loại",
+ "amount": "Số lượng",
+ "unit": "Đơn vị",
+ "unitPlaceholder": "Đơn vị",
+ "costPerUnit": "Chi phí",
+ "totalCost": "Tổng chi phí",
+ "tripDuration": "Thời gian chuyến đi",
+ "startDate": "Bắt đầu",
+ "endDate": "Kết thúc",
+ "selectDate": "Chọn ngày",
+ "selectStartDate": "Chọn ngày bắt đầu",
+ "selectEndDate": "Chọn ngày kết thúc",
+ "portLabel": " Cả",
+ "departurePort": "Cảng khởi hành",
+ "arrivalPort": "Cảng cập bến",
+ "selectPort": "Chọn cảng",
+ "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",
+ "autoFill": {
+ "title": "Tự động điền dữ liệu",
+ "description": "Điền từ chuyến đi cuối cùng của tàu",
+ "selectShip": "Chọn tàu",
+ "modalTitle": "Chọn tàu để lấy dữ liệu",
+ "loading": "Đang tải dữ liệu...",
+ "success": "Đã điền dữ liệu từ chuyến đi cuối cùng",
+ "error": "Không thể lấy dữ liệu chuyến đi",
+ "noData": "Không có dữ liệu chuyến đi trước đó"
}
},
"trip": {