Compare commits
7 Commits
4d60ce279e
...
MinhNN
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c3497d159 | |||
| 0e1332f433 | |||
| 347bd1a7c1 | |||
|
|
695066a5e7 | ||
| c47d9ad14c | |||
| e405a0bcfa | |||
| 0672f8adf9 |
277
add_trip_modal_summary.md
Normal file
277
add_trip_modal_summary.md
Normal file
@@ -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
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, themedStyles.addButton]}
|
||||
onPress={() => setShowAddTripModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.addButtonText}>{t("diary.addTrip")}</Text>
|
||||
</TouchableOpacity>
|
||||
```
|
||||
|
||||
4. **Add Modal Component:**
|
||||
```typescript
|
||||
<AddTripModal
|
||||
visible={showAddTripModal}
|
||||
onClose={() => 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!
|
||||
@@ -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" color={color} />
|
||||
<IconSymbol
|
||||
size={28}
|
||||
name="square.stack.3d.up.fill"
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,127 +1,323 @@
|
||||
import { useState } from "react";
|
||||
import { Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} 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 AddTripModal from "@/components/diary/addTripModal";
|
||||
import { useThings } from "@/state/use-thing";
|
||||
import { useTripsList } from "@/state/use-tripslist";
|
||||
import dayjs from "dayjs";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
export default function diary() {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterValues>({
|
||||
status: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
selectedShip: null, // Tàu được chọn
|
||||
});
|
||||
|
||||
// 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);
|
||||
// State for pagination
|
||||
const [allTrips, setAllTrips] = useState<any[]>([]);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isInitialLoad = useRef(true);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const handleSearch = (text: string) => {
|
||||
setSearchText(text);
|
||||
// Body call API things (đang fix cứng)
|
||||
const payloadThings: Model.SearchThingBody = {
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
order: "name",
|
||||
dir: "asc",
|
||||
metadata: {
|
||||
not_empty: "ship_name, ship_reg_number",
|
||||
},
|
||||
};
|
||||
|
||||
// Gọi API things
|
||||
const { getThings } = useThings();
|
||||
useEffect(() => {
|
||||
getThings(payloadThings);
|
||||
}, []);
|
||||
|
||||
// State cho payload trips
|
||||
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
|
||||
name: "",
|
||||
order: "",
|
||||
dir: "desc",
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
metadata: {
|
||||
from: "",
|
||||
to: "",
|
||||
ship_name: "",
|
||||
reg_number: "",
|
||||
province_code: "",
|
||||
owner_id: "",
|
||||
ship_id: "",
|
||||
status: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { tripsList, getTripsList, loading } = useTripsList();
|
||||
|
||||
// console.log("Payload trips:", payloadTrips);
|
||||
|
||||
// Gọi API trips lần đầu
|
||||
useEffect(() => {
|
||||
isInitialLoad.current = true;
|
||||
setAllTrips([]);
|
||||
setHasMore(true);
|
||||
getTripsList(payloadTrips);
|
||||
}, []);
|
||||
|
||||
// Xử lý khi nhận được dữ liệu mới từ API
|
||||
useEffect(() => {
|
||||
if (tripsList?.trips && tripsList.trips.length > 0) {
|
||||
if (isInitialLoad.current || payloadTrips.offset === 0) {
|
||||
// Load lần đầu hoặc reset do filter
|
||||
setAllTrips(tripsList.trips);
|
||||
isInitialLoad.current = false;
|
||||
} else {
|
||||
// Load more - append data
|
||||
setAllTrips((prevTrips) => {
|
||||
// Tránh duplicate bằng cách check ID
|
||||
const existingIds = new Set(prevTrips.map((trip) => trip.id));
|
||||
const newTrips = tripsList.trips!.filter(
|
||||
(trip) => !existingIds.has(trip.id)
|
||||
);
|
||||
return [...prevTrips, ...newTrips];
|
||||
});
|
||||
}
|
||||
|
||||
// Check if có thêm data không
|
||||
const totalFetched = payloadTrips.offset + tripsList.trips.length;
|
||||
setHasMore(totalFetched < (tripsList.total || 0));
|
||||
setIsLoadingMore(false);
|
||||
} else if (tripsList && payloadTrips.offset === 0) {
|
||||
// Trường hợp API trả về rỗng khi filter
|
||||
setAllTrips([]);
|
||||
setHasMore(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [tripsList]);
|
||||
|
||||
const handleFilter = () => {
|
||||
setShowFilterModal(true);
|
||||
};
|
||||
|
||||
const handleApplyFilters = (newFilters: FilterValues) => {
|
||||
setFilters(newFilters);
|
||||
|
||||
// Cập nhật payload với filter mới và reset offset
|
||||
// Lưu ý: status gửi lên server là string
|
||||
const updatedPayload: Model.TripListBody = {
|
||||
...payloadTrips,
|
||||
offset: 0, // Reset về đầu khi filter
|
||||
metadata: {
|
||||
...payloadTrips.metadata,
|
||||
from: newFilters.startDate
|
||||
? dayjs(newFilters.startDate).startOf("day").toISOString()
|
||||
: "",
|
||||
to: newFilters.endDate
|
||||
? dayjs(newFilters.endDate).endOf("day").toISOString()
|
||||
: "",
|
||||
// Convert number status sang string để gửi lên server
|
||||
status: newFilters.status !== null ? String(newFilters.status) : "",
|
||||
// Thêm ship_id từ tàu đã chọn
|
||||
ship_name: newFilters.selectedShip?.shipName || "",
|
||||
},
|
||||
};
|
||||
|
||||
// Reset trips khi apply filter mới
|
||||
setAllTrips([]);
|
||||
setHasMore(true);
|
||||
setPayloadTrips(updatedPayload);
|
||||
getTripsList(updatedPayload);
|
||||
setShowFilterModal(false);
|
||||
};
|
||||
|
||||
// Hàm load more data khi scroll đến cuối
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isLoadingMore || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMore(true);
|
||||
const newOffset = payloadTrips.offset + payloadTrips.limit;
|
||||
const updatedPayload = {
|
||||
...payloadTrips,
|
||||
offset: newOffset,
|
||||
};
|
||||
setPayloadTrips(updatedPayload);
|
||||
getTripsList(updatedPayload);
|
||||
}, [isLoadingMore, hasMore, payloadTrips]);
|
||||
|
||||
const handleTripPress = (tripId: string) => {
|
||||
// TODO: Navigate to trip detail
|
||||
console.log("Trip pressed:", tripId);
|
||||
};
|
||||
|
||||
const handleViewTrip = (tripId: string) => {
|
||||
console.log("View trip:", tripId);
|
||||
// TODO: Navigate to trip detail view
|
||||
};
|
||||
|
||||
const handleEditTrip = (tripId: string) => {
|
||||
console.log("Edit trip:", tripId);
|
||||
// TODO: Navigate to trip edit screen
|
||||
};
|
||||
|
||||
const handleViewTeam = (tripId: string) => {
|
||||
console.log("View team:", tripId);
|
||||
// TODO: Navigate to team management
|
||||
};
|
||||
|
||||
const handleSendTrip = (tripId: string) => {
|
||||
console.log("Send trip:", tripId);
|
||||
// TODO: Send trip for approval
|
||||
};
|
||||
|
||||
const handleDeleteTrip = (tripId: string) => {
|
||||
console.log("Delete trip:", tripId);
|
||||
// TODO: Show confirmation dialog and delete trip
|
||||
};
|
||||
|
||||
// Dynamic styles based on theme
|
||||
const themedStyles = {
|
||||
safeArea: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
titleText: {
|
||||
color: colors.text,
|
||||
},
|
||||
countText: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
};
|
||||
|
||||
// Render mỗi item trong FlatList
|
||||
const renderTripItem = useCallback(
|
||||
({ item }: { item: any }) => (
|
||||
<TripCard
|
||||
trip={item}
|
||||
onPress={() => handleTripPress(item.id)}
|
||||
onView={() => handleViewTrip(item.id)}
|
||||
onEdit={() => handleEditTrip(item.id)}
|
||||
onTeam={() => handleViewTeam(item.id)}
|
||||
onSend={() => handleSendTrip(item.id)}
|
||||
onDelete={() => handleDeleteTrip(item.id)}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// Key extractor cho FlatList
|
||||
const keyExtractor = useCallback((item: any) => item.id, []);
|
||||
|
||||
// Loading indicator khi load more
|
||||
const renderFooter = () => {
|
||||
// Không hiển thị loading footer nếu không có dữ liệu hoặc không đang load more
|
||||
if (!isLoadingMore || allTrips.length === 0) return null;
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.loadingFooter}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
|
||||
{t("diary.loadingMore")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Empty component
|
||||
const renderEmpty = () => {
|
||||
// Hiển thị loading khi đang load (lần đầu hoặc load more) và chưa có dữ liệu
|
||||
if (loading && allTrips.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Chỉ hiển thị "không có dữ liệu" khi đã load xong và thực sự không có trips
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, themedStyles.emptyText]}>
|
||||
{t("diary.noTripsFound")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Text style={styles.titleText}>Nhật ký chuyến đi</Text>
|
||||
<Text style={[styles.titleText, themedStyles.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")}
|
||||
style={[styles.addButton, themedStyles.addButton]}
|
||||
onPress={() => setShowAddTripModal(true)}
|
||||
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 List */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
{/* Trip Count */}
|
||||
<Text style={[styles.countText, themedStyles.countText]}>
|
||||
{t("diary.tripListCount", { count: tripsList?.total || 0 })}
|
||||
</Text>
|
||||
|
||||
{/* Trip List with FlatList */}
|
||||
<FlatList
|
||||
data={allTrips}
|
||||
renderItem={renderTripItem}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{filteredTrips.map((trip) => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
onPress={() => handleTripPress(trip.id)}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
initialNumToRender={10}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredTrips.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>
|
||||
Không tìm thấy chuyến đi phù hợp
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Filter Modal */}
|
||||
@@ -130,6 +326,12 @@ export default function diary() {
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
|
||||
{/* Add Trip Modal */}
|
||||
<AddTripModal
|
||||
visible={showAddTripModal}
|
||||
onClose={() => setShowAddTripModal(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -137,24 +339,29 @@ export default function diary() {
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F9FAFB",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
padding: 10,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
lineHeight: 36,
|
||||
marginBottom: 20,
|
||||
color: "#111827",
|
||||
marginBottom: 10,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
@@ -165,17 +372,16 @@ const styles = StyleSheet.create({
|
||||
countText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
marginBottom: 10,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#3B82F6",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
@@ -191,9 +397,6 @@ const styles = StyleSheet.create({
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
@@ -204,7 +407,21 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: "#9CA3AF",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
loadingFooter: {
|
||||
paddingVertical: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
import DraggablePanel from "@/components/DraggablePanel";
|
||||
import IconButton from "@/components/IconButton";
|
||||
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
||||
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
||||
import AlarmList from "@/components/map/AlarmList";
|
||||
import ShipInfo from "@/components/map/ShipInfo";
|
||||
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
|
||||
import ZoneInMap from "@/components/map/ZoneInMap";
|
||||
import ShipSearchForm, {
|
||||
SearchShipResponse,
|
||||
} from "@/components/ShipSearchForm";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
||||
import { queryBanzoneById } from "@/controller/MapController";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { searchThingEventBus } from "@/services/device_events";
|
||||
import { getShipIcon } from "@/services/map_service";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
import { AntDesign } from "@expo/vector-icons";
|
||||
import { AntDesign, Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import MapView, { Marker } from "react-native-maps";
|
||||
|
||||
interface ZoneDataParsed {
|
||||
zone_type?: number;
|
||||
zone_name?: string;
|
||||
zone_id?: string;
|
||||
message?: string;
|
||||
alarm_type?: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
s?: number;
|
||||
h?: number;
|
||||
fishing?: boolean;
|
||||
gps_time?: number;
|
||||
}
|
||||
export interface AlarmData {
|
||||
thing_id: string;
|
||||
ship_name?: string;
|
||||
zone: ZoneDataParsed;
|
||||
type: "approaching" | "entered" | "fishing";
|
||||
}
|
||||
|
||||
export interface BanzoneWithAlarm {
|
||||
alarms: AlarmData;
|
||||
zone?: Model.Zone;
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
||||
const [trackPointsData, setTrackPointsData] = useState<
|
||||
Model.ShipTrackPoint[] | null
|
||||
>(null);
|
||||
const [circleRadius, setCircleRadius] = useState(100);
|
||||
const [zoomLevel, setZoomLevel] = useState(10);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
const [alarms, setAlarms] = useState<AlarmData[]>([]);
|
||||
const [banzoneWithAlarm, setBanzoneWithAlarm] =
|
||||
useState<BanzoneWithAlarm | null>(null);
|
||||
const [allBanZones, setAllBanZones] = useState<BanzoneWithAlarm[]>([]);
|
||||
const [showAllAlarmsOnMap, setShowAllAlarmsOnMap] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||
PolylineWithLabelProps[]
|
||||
>([]);
|
||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||
PolygonWithLabelProps[]
|
||||
>([]);
|
||||
|
||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
|
||||
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
||||
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
||||
@@ -54,15 +77,36 @@ export default function HomeScreen() {
|
||||
const [tagStatePayload, setTagStatePayload] =
|
||||
useState<TagStateCallbackPayload | null>(null);
|
||||
|
||||
const [isShowAlarmList, setIsShowAlarmList] = useState(false);
|
||||
// Control mount so we can animate close before unmounting
|
||||
const [isAlarmListMounted, setIsAlarmListMounted] = useState(false);
|
||||
// Thêm state để quản lý tracksViewChanges
|
||||
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||
|
||||
// Alarm list animation
|
||||
const screenHeight = Dimensions.get("window").height;
|
||||
const alarmListHeight = Math.round(screenHeight * 0.3);
|
||||
const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current;
|
||||
const alarmOpacity = useRef(new Animated.Value(0)).current;
|
||||
const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden
|
||||
|
||||
useEffect(() => {
|
||||
if (tagStatePayload) {
|
||||
searchThings();
|
||||
}
|
||||
}, [tagStatePayload]);
|
||||
|
||||
const openAlarmList = () => {
|
||||
setIsAlarmListMounted(true);
|
||||
setIsShowAlarmList(true);
|
||||
};
|
||||
|
||||
const closeAlarmList = () => {
|
||||
// Trigger the animation; the effect will unmount when complete
|
||||
setIsShowAlarmList(false);
|
||||
setBanzoneWithAlarm(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shipSearchFormData) {
|
||||
searchThings();
|
||||
@@ -73,7 +117,7 @@ export default function HomeScreen() {
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
order: "name",
|
||||
sort: "asc",
|
||||
dir: "asc",
|
||||
metadata: {
|
||||
not_empty: "ship_id",
|
||||
},
|
||||
@@ -93,7 +137,7 @@ export default function HomeScreen() {
|
||||
...thingsData,
|
||||
things: sortedThings,
|
||||
};
|
||||
console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
||||
// console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
||||
|
||||
setThings(sortedThingsResponse);
|
||||
};
|
||||
@@ -105,101 +149,85 @@ export default function HomeScreen() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (things) {
|
||||
// console.log("Things Updated: ", things.things?.length);
|
||||
// const gpsDatas: Model.GPSResponse[] = [];
|
||||
// for (const thing of things.things || []) {
|
||||
// if (thing.metadata?.gps) {
|
||||
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps);
|
||||
// gpsDatas.push(gps);
|
||||
// }
|
||||
// }
|
||||
// console.log("GPS Lenght: ", gpsDatas.length);
|
||||
// setGpsData(gpsDatas);
|
||||
if (things?.things) {
|
||||
const alarmTypes = [
|
||||
{
|
||||
key: "zone_approaching_alarm_list",
|
||||
type: "approaching" as const,
|
||||
},
|
||||
{ key: "zone_entered_alarm_list", type: "entered" as const },
|
||||
{ key: "zone_fishing_alarm_list", type: "fishing" as const },
|
||||
];
|
||||
|
||||
const newAlarms: AlarmData[] = [];
|
||||
|
||||
for (const thing of things.things) {
|
||||
for (const { key, type } of alarmTypes) {
|
||||
if ((thing.metadata as any)?.[key] != "[]") {
|
||||
const zoneList: ZoneDataParsed[] = JSON.parse(
|
||||
(thing?.metadata as any)?.[key] || "[]"
|
||||
);
|
||||
for (const zone of zoneList) {
|
||||
const alarmData: AlarmData = {
|
||||
thing_id: thing.id || "",
|
||||
ship_name: thing.metadata?.ship_name,
|
||||
zone: zone,
|
||||
type: type,
|
||||
};
|
||||
newAlarms.push(alarmData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update alarms, removing old ones not in newAlarms
|
||||
setAlarms((prev) => {
|
||||
const toKeep = prev.filter((a) =>
|
||||
newAlarms.some(
|
||||
(na) =>
|
||||
na.thing_id === a.thing_id && na.zone.zone_id === a.zone.zone_id
|
||||
)
|
||||
);
|
||||
const toAdd = newAlarms.filter(
|
||||
(na) =>
|
||||
!prev.some(
|
||||
(a) =>
|
||||
a.thing_id === na.thing_id && a.zone.zone_id === a.zone.zone_id
|
||||
)
|
||||
);
|
||||
return [...toKeep, ...toAdd];
|
||||
});
|
||||
}
|
||||
}, [things]);
|
||||
|
||||
// useEffect(() => {
|
||||
// setPolylineCoordinates([]);
|
||||
// setPolygonCoordinates([]);
|
||||
// if (!entityData) return;
|
||||
// if (!banzoneData) return;
|
||||
// for (const entity of entityData) {
|
||||
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||
// continue;
|
||||
// }
|
||||
useEffect(() => {
|
||||
const approaching = alarms.filter((a) => a.type === "approaching");
|
||||
const entered = alarms.filter((a) => a.type === "entered");
|
||||
const fishing = alarms.filter((a) => a.type === "fishing");
|
||||
|
||||
// let zones: any[] = [];
|
||||
// try {
|
||||
// zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||
// } catch (parseError) {
|
||||
// console.error("Error parsing zone list:", parseError);
|
||||
// continue;
|
||||
// }
|
||||
// // Nếu danh sách zone rỗng, clear tất cả
|
||||
// if (zones.length === 0) {
|
||||
// setPolylineCoordinates([]);
|
||||
// setPolygonCoordinates([]);
|
||||
// return;
|
||||
// }
|
||||
if (approaching.length > 0) {
|
||||
console.log("ZoneApproachingAlarm: ", approaching);
|
||||
} else {
|
||||
// console.log("No ZoneApproachingAlarm");
|
||||
}
|
||||
if (entered.length > 0) {
|
||||
console.log("ZoneEnteredAlarm: ", entered);
|
||||
} else {
|
||||
// console.log("No ZoneEnteredAlarm");
|
||||
}
|
||||
if (fishing.length > 0) {
|
||||
console.log("ZoneFishingAlarm: ", fishing);
|
||||
} else {
|
||||
// console.log("No ZoneFishingAlarm");
|
||||
}
|
||||
}, [alarms]);
|
||||
|
||||
// let polylines: PolylineWithLabelProps[] = [];
|
||||
// let polygons: PolygonWithLabelProps[] = [];
|
||||
|
||||
// for (const zone of zones) {
|
||||
// // console.log("Zone Data: ", zone);
|
||||
// const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
||||
// if (!geom) {
|
||||
// continue;
|
||||
// }
|
||||
// const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||
// if (typeof geom_type !== "number") {
|
||||
// continue;
|
||||
// }
|
||||
// if (geom_type === 2) {
|
||||
// // if(oldEntityData.find(e => e.id === ))
|
||||
// // foundPolyline = true;
|
||||
// const coordinates = convertWKTLineStringToLatLngArray(
|
||||
// geom_lines || ""
|
||||
// );
|
||||
// if (coordinates.length > 0) {
|
||||
// polylines.push({
|
||||
// coordinates: coordinates.map((coord) => ({
|
||||
// latitude: coord[0],
|
||||
// longitude: coord[1],
|
||||
// })),
|
||||
// label: zone?.zone_name ?? "",
|
||||
// content: zone?.message ?? "",
|
||||
// });
|
||||
// } else {
|
||||
// console.log("Không tìm thấy polyline trong alarm");
|
||||
// }
|
||||
// } else if (geom_type === 1) {
|
||||
// // foundPolygon = true;
|
||||
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||
// if (coordinates.length > 0) {
|
||||
// // console.log("Polygon Coordinate: ", coordinates);
|
||||
// const zonePolygons = coordinates.map((polygon) => ({
|
||||
// coordinates: polygon.map((coord) => ({
|
||||
// latitude: coord[0],
|
||||
// longitude: coord[1],
|
||||
// })),
|
||||
// label: zone?.zone_name ?? "",
|
||||
// content: zone?.message ?? "",
|
||||
// }));
|
||||
// polygons.push(...zonePolygons);
|
||||
// } else {
|
||||
// console.log("Không tìm thấy polygon trong alarm");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// setPolylineCoordinates(polylines);
|
||||
// setPolygonCoordinates(polygons);
|
||||
// }
|
||||
// }, [banzoneData, entityData]);
|
||||
|
||||
// Hàm tính radius cố định khi zoom change
|
||||
// Load all banzones when alarms change (for showing all alarms on map)
|
||||
useEffect(() => {
|
||||
if (alarms.length > 0 && showAllAlarmsOnMap) {
|
||||
loadAllBanZones();
|
||||
}
|
||||
}, [alarms, showAllAlarmsOnMap]);
|
||||
|
||||
const calculateRadiusFromZoom = (zoom: number) => {
|
||||
const baseZoom = 10;
|
||||
@@ -217,8 +245,8 @@ export default function HomeScreen() {
|
||||
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
||||
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
||||
const newRadius = calculateRadiusFromZoom(zoom);
|
||||
setCircleRadius(newRadius);
|
||||
setZoomLevel(zoom);
|
||||
// setCircleRadius(newRadius);
|
||||
// setZoomLevel(zoom);
|
||||
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
||||
};
|
||||
|
||||
@@ -303,8 +331,53 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [isFirstLoad]);
|
||||
|
||||
// Animate alarm panel when isShowAlarmList changes
|
||||
// Keep the overlay mounted while animating it in/out.
|
||||
useEffect(() => {
|
||||
if (isShowAlarmList) {
|
||||
// Ensure mounted then animate to visible
|
||||
setIsAlarmListMounted(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(alarmTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(alarmOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
// Animate out, then unmount
|
||||
Animated.parallel([
|
||||
Animated.timing(alarmTranslateY, {
|
||||
toValue: alarmListHeight,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(alarmOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setIsAlarmListMounted(false);
|
||||
});
|
||||
}
|
||||
}, [alarmTranslateY, alarmListHeight, alarmOpacity, isShowAlarmList]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(uiAnim, {
|
||||
toValue: isAlarmListMounted ? 0 : 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [uiAnim, isAlarmListMounted]);
|
||||
|
||||
const searchThings = async () => {
|
||||
console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
||||
// console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
||||
|
||||
// Xây dựng query state dựa trên logic bạn cung cấp
|
||||
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
|
||||
@@ -384,14 +457,14 @@ export default function HomeScreen() {
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
order: "name",
|
||||
sort: "asc",
|
||||
dir: "asc",
|
||||
metadata: {
|
||||
...metaFormQuery,
|
||||
...metaStateQuery,
|
||||
not_empty: "ship_id",
|
||||
},
|
||||
};
|
||||
console.log("Search Params: ", searchParams);
|
||||
// console.log("Search Params: ", searchParams);
|
||||
|
||||
// Gọi API tìm kiếm
|
||||
searchThingEventBus(searchParams);
|
||||
@@ -401,6 +474,50 @@ export default function HomeScreen() {
|
||||
setShipSearchFormOpen(false);
|
||||
};
|
||||
|
||||
const loadAllBanZones = async () => {
|
||||
try {
|
||||
const banzonePromises = alarms.map(async (alarm) => {
|
||||
try {
|
||||
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||
return {
|
||||
alarms: alarm,
|
||||
zone: banzone.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Cannot get banzone for zone_id: ${alarm.zone.zone_id}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const banzoneResults = await Promise.all(banzonePromises);
|
||||
const validBanZones = banzoneResults.filter(
|
||||
(banzone): banzone is NonNullable<typeof banzone> => banzone !== null
|
||||
);
|
||||
setAllBanZones(validBanZones);
|
||||
} catch (error) {
|
||||
console.error("Error loading all banzones:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlarmPress = async (alarm: AlarmData) => {
|
||||
console.log("Alarm pressed from list:", alarm);
|
||||
try {
|
||||
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||
// console.log("Banzone API response:", banzone);
|
||||
// console.log("Banzone data:", banzone.data);
|
||||
// console.log("Zone geometry:", banzone.data?.geometry);
|
||||
const banzoneWithAlarm: BanzoneWithAlarm = {
|
||||
alarms: alarm,
|
||||
zone: banzone.data,
|
||||
};
|
||||
|
||||
setBanzoneWithAlarm(banzoneWithAlarm);
|
||||
setShowAllAlarmsOnMap(false);
|
||||
} catch (error) {
|
||||
console.error("Cannot get Banzone:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = shipSearchFormData
|
||||
? shipSearchFormData.ship_name !== "" ||
|
||||
shipSearchFormData.reg_number !== "" ||
|
||||
@@ -418,6 +535,7 @@ export default function HomeScreen() {
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
onMapReady={handleMapReady}
|
||||
onRegionChangeComplete={handleRegionChangeComplete}
|
||||
style={styles.map}
|
||||
@@ -428,8 +546,9 @@ export default function HomeScreen() {
|
||||
loadingEnabled={true}
|
||||
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||
rotateEnabled={false}
|
||||
// onMarkerPress={onMarkerPress}
|
||||
>
|
||||
{things?.things && things.things.length > 0 && (
|
||||
{!banzoneWithAlarm && things?.things && things.things.length > 0 && (
|
||||
<>
|
||||
{things.things
|
||||
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
||||
@@ -454,6 +573,10 @@ export default function HomeScreen() {
|
||||
}}
|
||||
zIndex={50}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
title={thing.metadata?.ship_name}
|
||||
description={`Trạng thái: ${
|
||||
gpsData.fishing ? "Đang đánh bắt" : "Không đánh bắt"
|
||||
}`}
|
||||
// Chỉ tracks changes khi cần thiết
|
||||
tracksViewChanges={
|
||||
platform === IOS_PLATFORM ? tracksViewChanges : true
|
||||
@@ -461,6 +584,7 @@ export default function HomeScreen() {
|
||||
// Thêm identifier để iOS optimize
|
||||
identifier={uniqueKey}
|
||||
>
|
||||
{/* <Callout tooltip></Callout> */}
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<View style={styles.pingContainer}>
|
||||
{thing.metadata?.state_level === 3 && (
|
||||
@@ -507,9 +631,16 @@ export default function HomeScreen() {
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{(banzoneWithAlarm ||
|
||||
(showAllAlarmsOnMap && allBanZones.length > 0)) && (
|
||||
<ZoneInMap
|
||||
banzones={banzoneWithAlarm ? [banzoneWithAlarm] : allBanZones}
|
||||
mapRef={mapRef}
|
||||
/>
|
||||
)}
|
||||
</MapView>
|
||||
<View className="absolute top-20 left-5">
|
||||
{!isPanelExpanded && (
|
||||
<View className="absolute top-12 left-5">
|
||||
{!isAlarmListMounted && !isPanelExpanded && (
|
||||
<IconButton
|
||||
icon={<AntDesign name="filter" size={16} />}
|
||||
type="primary"
|
||||
@@ -528,12 +659,13 @@ export default function HomeScreen() {
|
||||
<GPSInfoPanel gpsData={gpsData!} /> */}
|
||||
|
||||
{/* Draggable Panel */}
|
||||
{!isAlarmListMounted && (
|
||||
<DraggablePanel
|
||||
minHeightPct={0.1}
|
||||
maxHeightPct={0.6}
|
||||
initialState="min"
|
||||
onExpandedChange={(expanded) => {
|
||||
console.log("Panel expanded:", expanded);
|
||||
// console.log("Panel expanded:", expanded);
|
||||
setIsPanelExpanded(expanded);
|
||||
}}
|
||||
>
|
||||
@@ -618,12 +750,67 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
</>
|
||||
</DraggablePanel>
|
||||
)}
|
||||
{/* Alarm list overlay */}
|
||||
{isAlarmListMounted && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: alarmListHeight,
|
||||
transform: [{ translateY: alarmTranslateY }],
|
||||
opacity: alarmOpacity,
|
||||
elevation: 10,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<View className="bg-white rounded-t-3xl shadow-md overflow-hidden h-full z-50">
|
||||
<View className="flex-row items-center justify-between px-4 py-2">
|
||||
<Text className="text-lg font-semibold">
|
||||
Danh sách cảnh báo
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => closeAlarmList()}
|
||||
style={{ padding: 6 }}
|
||||
>
|
||||
<Ionicons name="close" size={20} color="#374151" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#F9FAFB",
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
{/* <ThemedText className="text-lg font-semibold">Body</ThemedText> */}
|
||||
<AlarmList data={alarms} onPress={handleAlarmPress} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
<ShipSearchForm
|
||||
initialValues={shipSearchFormData}
|
||||
isOpen={shipSearchFormOpen}
|
||||
onClose={() => setShipSearchFormOpen(false)}
|
||||
onSubmit={handleOnSubmitSearchForm}
|
||||
/>
|
||||
{!isAlarmListMounted && alarms.length > 0 && (
|
||||
<View className="absolute top-12 right-5 space-y-2">
|
||||
<IconButton
|
||||
icon={<Ionicons name="warning" size={16} color="#fff" />}
|
||||
type="danger"
|
||||
size="middle"
|
||||
onPress={() => openAlarmList()}
|
||||
>
|
||||
<ThemedText className="text-sm font-semibold">
|
||||
{alarms.length}
|
||||
</ThemedText>
|
||||
</IconButton>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,323 @@
|
||||
import ShipSearchForm from "@/components/ShipSearchForm";
|
||||
import { useState } from "react";
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AlarmData } from ".";
|
||||
|
||||
// ============ Types ============
|
||||
type AlarmType = "approaching" | "entered" | "fishing";
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: AlarmData;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
// ============ Config ============
|
||||
const ALARM_CONFIG: Record<
|
||||
AlarmType,
|
||||
{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
labelColor: string;
|
||||
}
|
||||
> = {
|
||||
entered: {
|
||||
icon: "warning",
|
||||
label: "Xâm nhập",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconBgColor: "bg-red-100",
|
||||
iconColor: "#DC2626",
|
||||
labelColor: "text-red-600",
|
||||
},
|
||||
approaching: {
|
||||
icon: "alert-circle",
|
||||
label: "Tiếp cận",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
iconBgColor: "bg-amber-100",
|
||||
iconColor: "#D97706",
|
||||
labelColor: "text-amber-600",
|
||||
},
|
||||
fishing: {
|
||||
icon: "fish",
|
||||
label: "Đánh bắt",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200",
|
||||
iconBgColor: "bg-orange-100",
|
||||
iconColor: "#EA580C",
|
||||
labelColor: "text-orange-600",
|
||||
},
|
||||
};
|
||||
|
||||
// ============ Helper Functions ============
|
||||
const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
|
||||
// ============ AlarmCard Component ============
|
||||
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
||||
const config = ALARM_CONFIG[alarm.type];
|
||||
|
||||
export default function warning() {
|
||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.titleText}>Cảnh báo</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||
>
|
||||
<View className="flex-row items-start gap-3">
|
||||
{/* Icon Container */}
|
||||
<View
|
||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
<ShipSearchForm
|
||||
isOpen={shipSearchFormOpen}
|
||||
onClose={() => setShipSearchFormOpen(false)}
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1">
|
||||
{/* Header: Ship name + Badge */}
|
||||
<View className="flex-row items-center justify-between mb-1">
|
||||
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||
{alarm.ship_name || alarm.thing_id}
|
||||
</ThemedText>
|
||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||
<ThemedText
|
||||
className={`text-xs font-semibold ${config.labelColor}`}
|
||||
>
|
||||
{config.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Zone Info */}
|
||||
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
||||
{alarm.zone.message || alarm.zone.zone_name}
|
||||
</ThemedText>
|
||||
|
||||
{/* Footer: Zone ID + Time */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||
<ThemedText className="text-xs text-gray-500">
|
||||
{formatTimestamp(alarm.zone.gps_time)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
AlarmCard.displayName = "AlarmCard";
|
||||
|
||||
// ============ Main Component ============
|
||||
interface WarningScreenProps {
|
||||
alarms?: AlarmData[];
|
||||
}
|
||||
|
||||
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
||||
// Mock data for demo - replace with actual props
|
||||
const sampleAlarms: AlarmData[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
thing_id: "SHIP-001",
|
||||
ship_name: "Ocean Star",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực cấm A1",
|
||||
zone_id: "A1",
|
||||
message: "Tàu đã đi vào vùng cấm A1",
|
||||
alarm_type: 1,
|
||||
lat: 10.12345,
|
||||
lon: 106.12345,
|
||||
s: 12,
|
||||
h: 180,
|
||||
fishing: false,
|
||||
gps_time: 1733389200,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-002",
|
||||
ship_name: "Blue Whale",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng cảnh báo B3",
|
||||
zone_id: "B3",
|
||||
message: "Tàu đang tiếp cận khu vực cấm B3",
|
||||
alarm_type: 2,
|
||||
lat: 9.87654,
|
||||
lon: 105.87654,
|
||||
gps_time: 1733389260,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-003",
|
||||
ship_name: "Sea Dragon",
|
||||
type: "fishing",
|
||||
zone: {
|
||||
zone_type: 3,
|
||||
zone_name: "Vùng cấm đánh bắt C2",
|
||||
zone_id: "C2",
|
||||
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
||||
alarm_type: 3,
|
||||
lat: 11.11223,
|
||||
lon: 107.44556,
|
||||
fishing: true,
|
||||
gps_time: 1733389320,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-004",
|
||||
ship_name: "Red Coral",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực A2",
|
||||
zone_id: "A2",
|
||||
message: "Tàu đã đi sâu vào khu vực A2",
|
||||
alarm_type: 1,
|
||||
gps_time: 1733389380,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-005",
|
||||
ship_name: "Silver Wind",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng B1",
|
||||
zone_id: "B1",
|
||||
message: "Tàu đang tiến gần vào vùng B1",
|
||||
alarm_type: 2,
|
||||
gps_time: 1733389440,
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
||||
|
||||
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
||||
console.log("Alarm pressed:", alarm);
|
||||
// TODO: Navigate to alarm detail or show modal
|
||||
}, []);
|
||||
|
||||
const renderAlarmCard = useCallback(
|
||||
({ item }: { item: AlarmData }) => (
|
||||
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
||||
),
|
||||
[handleAlarmPress]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||
|
||||
// Count alarms by type
|
||||
const alarmCounts = useMemo(() => {
|
||||
return displayAlarms.reduce((acc, alarm) => {
|
||||
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<AlarmType, number>);
|
||||
}, [displayAlarms]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||
<ThemedView style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
||||
<Ionicons name="warning" size={22} color="#fff" />
|
||||
</View>
|
||||
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||
</View>
|
||||
<View className="bg-red-500 px-3 py-1 rounded-full">
|
||||
<ThemedText className="text-white text-sm font-semibold">
|
||||
{displayAlarms.length}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<View className="flex-row px-4 pb-3 gap-2">
|
||||
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
||||
(type) => {
|
||||
const config = ALARM_CONFIG[type];
|
||||
const count = alarmCounts[type] || 0;
|
||||
return (
|
||||
<View
|
||||
key={type}
|
||||
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={14}
|
||||
color={config.iconColor}
|
||||
/>
|
||||
</ScrollView>
|
||||
<ThemedText
|
||||
className={`text-xs font-medium ${config.labelColor}`}
|
||||
>
|
||||
{count}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Alarm List */}
|
||||
<FlatList
|
||||
data={displayAlarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
/>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 15,
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 32,
|
||||
fontSize: 26,
|
||||
fontWeight: "700",
|
||||
lineHeight: 40,
|
||||
marginBottom: 30,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
type AlarmItem = {
|
||||
name: string;
|
||||
t: number;
|
||||
level: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type AlarmProp = {
|
||||
alarmsData: AlarmItem[];
|
||||
onPress?: (alarm: AlarmItem) => void;
|
||||
};
|
||||
|
||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||
const sortedAlarmsData = [...alarmsData].sort((a, b) => b.level - a.level);
|
||||
return (
|
||||
<FlatList
|
||||
data={sortedAlarmsData}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => onPress?.(item)}
|
||||
className="flex flex-row gap-5 p-3 justify-start items-baseline w-full"
|
||||
>
|
||||
<View
|
||||
className={`flex-none h-3 w-3 rounded-full ${getBackgroundColorByLevel(
|
||||
item.level
|
||||
)}`}
|
||||
></View>
|
||||
<View className="flex">
|
||||
<Text className={`grow text-lg ${getTextColorByLevel(item.level)}`}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text className="grow text-md text-gray-400">
|
||||
{formatTimestamp(item.t)}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getBackgroundColorByLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "bg-yellow-500";
|
||||
case 2:
|
||||
return "bg-orange-500";
|
||||
case 3:
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColorByLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return "text-yellow-600";
|
||||
case 2:
|
||||
return "text-orange-600";
|
||||
case 3:
|
||||
return "text-red-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
|
||||
export default AlarmList;
|
||||
197
components/alarm/WarningCard.tsx
Normal file
197
components/alarm/WarningCard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export type AlarmStatus = "confirmed" | "pending";
|
||||
|
||||
export interface AlarmListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
station: string;
|
||||
timestamp: number;
|
||||
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
|
||||
status: AlarmStatus;
|
||||
}
|
||||
|
||||
type AlarmProp = {
|
||||
alarmsData: AlarmListItem[];
|
||||
onPress?: (alarm: AlarmListItem) => void;
|
||||
};
|
||||
|
||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||
return (
|
||||
<FlatList
|
||||
data={alarmsData}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
|
||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||
renderItem={({ item }) => (
|
||||
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AlarmCardProps = {
|
||||
alarm: AlarmListItem;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
|
||||
alarm.level
|
||||
);
|
||||
const statusConfig = getStatusConfig(alarm.status);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
|
||||
>
|
||||
<View className="flex-row justify-between items-start">
|
||||
{/* Left content */}
|
||||
<View className="flex-row flex-1">
|
||||
{/* Icon */}
|
||||
<View
|
||||
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={getIconByLevel(alarm.level)}
|
||||
size={20}
|
||||
color={iconColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View className="flex-1">
|
||||
{/* Code */}
|
||||
<Text
|
||||
className={`text-xs font-medium mb-1 ${getCodeTextColor(
|
||||
alarm.level
|
||||
)}`}
|
||||
>
|
||||
{alarm.code}
|
||||
</Text>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="text-base font-semibold text-gray-800 mb-2">
|
||||
{alarm.title}
|
||||
</Text>
|
||||
|
||||
{/* Station and Time */}
|
||||
<View className="flex-row">
|
||||
<View className="mr-6">
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
|
||||
<Text className="text-sm text-gray-600">{alarm.station}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{formatTimestamp(alarm.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Badge */}
|
||||
{/* <View className="mt-3">
|
||||
<View
|
||||
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-medium ${statusConfig.textColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Checkmark for confirmed */}
|
||||
{/* {alarm.status === "confirmed" && (
|
||||
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
</View>
|
||||
)} */}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const getColorsByLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 3: // Danger - Red
|
||||
return {
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconColor: "#DC2626",
|
||||
iconBgColor: "bg-red-100",
|
||||
};
|
||||
case 2: // Caution - Yellow/Orange
|
||||
return {
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
iconColor: "#CA8A04",
|
||||
iconBgColor: "bg-yellow-100",
|
||||
};
|
||||
case 1: // Info - Green
|
||||
default:
|
||||
return {
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
iconColor: "#16A34A",
|
||||
iconBgColor: "bg-green-100",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "warning";
|
||||
case 2:
|
||||
return "alert-circle";
|
||||
case 1:
|
||||
default:
|
||||
return "checkmark-circle";
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeTextColor = (level: number) => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "text-red-600";
|
||||
case 2:
|
||||
return "text-yellow-600";
|
||||
case 1:
|
||||
default:
|
||||
return "text-green-600";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: AlarmStatus) => {
|
||||
switch (status) {
|
||||
case "confirmed":
|
||||
return {
|
||||
label: "Đã xác nhận",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-700",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
label: "Chờ xác nhận",
|
||||
bgColor: "bg-yellow-100",
|
||||
textColor: "text-yellow-700",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||
};
|
||||
|
||||
export default AlarmList;
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} 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 DateRangePickerProps {
|
||||
startDate: Date | null;
|
||||
@@ -23,6 +25,8 @@ export default function DateRangePicker({
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}: DateRangePickerProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors, colorScheme } = useThemeContext();
|
||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||
|
||||
@@ -48,36 +52,65 @@ export default function DateRangePicker({
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamic styles based on theme
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Ngày đi</Text>
|
||||
<Text style={[styles.label, themedStyles.label]}>{t("diary.dateRangePicker.label")}</Text>
|
||||
<View style={styles.dateRangeContainer}>
|
||||
{/* Start Date */}
|
||||
<TouchableOpacity
|
||||
style={styles.dateInput}
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.dateText, !startDate && styles.placeholder]}>
|
||||
{startDate ? formatDate(startDate) : "Ngày bắt đầu"}
|
||||
<Text style={[styles.dateText, themedStyles.dateText, !startDate && themedStyles.placeholder]}>
|
||||
{startDate ? formatDate(startDate) : t("diary.dateRangePicker.startDate")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Ionicons
|
||||
name="arrow-forward"
|
||||
size={20}
|
||||
color="#9CA3AF"
|
||||
color={colors.textSecondary}
|
||||
style={styles.arrow}
|
||||
/>
|
||||
|
||||
{/* End Date */}
|
||||
<TouchableOpacity
|
||||
style={styles.dateInput}
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowEndPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.dateText, !endDate && styles.placeholder]}>
|
||||
{endDate ? formatDate(endDate) : "Ngày kết thúc"}
|
||||
<Text style={[styles.dateText, themedStyles.dateText, !endDate && themedStyles.placeholder]}>
|
||||
{endDate ? formatDate(endDate) : t("diary.dateRangePicker.endDate")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -85,7 +118,7 @@ export default function DateRangePicker({
|
||||
style={styles.calendarButton}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={20} color="#6B7280" />
|
||||
<Ionicons name="calendar-outline" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -93,14 +126,14 @@ export default function DateRangePicker({
|
||||
{showStartPicker && (
|
||||
<Modal transparent animationType="fade" visible={showStartPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.pickerContainer}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={styles.cancelButton}>Hủy</Text>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>{t("common.cancel")}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.pickerTitle}>Chọn ngày bắt đầu</Text>
|
||||
<Text style={[styles.pickerTitle, themedStyles.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
|
||||
@@ -109,6 +142,8 @@ export default function DateRangePicker({
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleStartDateChange}
|
||||
maximumDate={endDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -119,14 +154,14 @@ export default function DateRangePicker({
|
||||
{showEndPicker && (
|
||||
<Modal transparent animationType="fade" visible={showEndPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.pickerContainer}>
|
||||
<View style={styles.pickerHeader}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||
<Text style={styles.cancelButton}>Hủy</Text>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>{t("common.cancel")}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.pickerTitle}>Chọn ngày kết thúc</Text>
|
||||
<Text style={[styles.pickerTitle, themedStyles.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
|
||||
@@ -135,6 +170,8 @@ export default function DateRangePicker({
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleEndDateChange}
|
||||
minimumDate={startDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -151,7 +188,6 @@ const styles = StyleSheet.create({
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 8,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
@@ -166,25 +202,19 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dateInput: {
|
||||
flex: 1,
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#D1D5DB",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 16,
|
||||
color: "#111827",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
color: "#9CA3AF",
|
||||
},
|
||||
arrow: {
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
@@ -197,7 +227,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
pickerContainer: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingBottom: 20,
|
||||
@@ -209,12 +238,10 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F3F4F6",
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -223,7 +250,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cancelButton: {
|
||||
fontSize: 16,
|
||||
color: "#6B7280",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -241,3 +267,4 @@ const styles = StyleSheet.create({
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
import React from "react";
|
||||
import { TouchableOpacity, Text, 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 FilterButtonProps {
|
||||
onPress?: () => void;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
export default function FilterButton({ onPress }: FilterButtonProps) {
|
||||
export default function FilterButton({
|
||||
onPress,
|
||||
isFiltered,
|
||||
}: FilterButtonProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
const themedStyles = {
|
||||
button: {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
text: {
|
||||
color: isFiltered ? colors.primary : colors.textSecondary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
style={[styles.button, themedStyles.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 ? colors.primary : colors.textSecondary}
|
||||
/>
|
||||
<Text style={[styles.text, themedStyles.text]}>
|
||||
{t("diary.filter")}
|
||||
</Text>
|
||||
{isFiltered && (
|
||||
<Ionicons
|
||||
name="ellipse"
|
||||
size={10}
|
||||
color={colors.primary}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +57,10 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5E7EB",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
@@ -42,7 +73,6 @@ const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#374151",
|
||||
marginLeft: 8,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
|
||||
@@ -11,7 +11,34 @@ 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";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
// 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 +46,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 +63,67 @@ export default function FilterModal({
|
||||
onClose,
|
||||
onApply,
|
||||
}: FilterModalProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
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;
|
||||
|
||||
const themedStyles = {
|
||||
modalContainer: {
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
header: {
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
},
|
||||
previewContainer: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
previewTitle: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
filterTag: {
|
||||
backgroundColor: colors.primary + '20', // 20% opacity
|
||||
},
|
||||
filterTagText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
footer: {
|
||||
borderTopColor: colors.separator,
|
||||
},
|
||||
resetButton: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
resetButtonText: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
applyButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -60,16 +138,16 @@ export default function FilterModal({
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalContainer}
|
||||
style={[styles.modalContainer, themedStyles.modalContainer]}
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, themedStyles.header]}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="#111827" />
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Bộ lọc</Text>
|
||||
<Text style={[styles.title, themedStyles.title]}>{t("diary.filter")}</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
@@ -85,29 +163,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 && (
|
||||
<View style={styles.filterTag}>
|
||||
<Text style={styles.filterTagText}>
|
||||
Trạng thái: {status}
|
||||
<View style={[styles.previewContainer, themedStyles.previewContainer]}>
|
||||
<Text style={[styles.previewTitle, themedStyles.previewTitle]}>{t("diary.selectedFilters")}</Text>
|
||||
{status !== null && (
|
||||
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||
{t("diary.statusLabel")} {mapStatusNumberToString(status)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{startDate && (
|
||||
<View style={styles.filterTag}>
|
||||
<Text style={styles.filterTagText}>
|
||||
Từ: {startDate.toLocaleDateString("vi-VN")}
|
||||
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||
{t("diary.fromLabel")} {startDate.toLocaleDateString("vi-VN")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{endDate && (
|
||||
<View style={styles.filterTag}>
|
||||
<Text style={styles.filterTagText}>
|
||||
Đến: {endDate.toLocaleDateString("vi-VN")}
|
||||
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||
{t("diary.toLabel")} {endDate.toLocaleDateString("vi-VN")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{selectedShip && (
|
||||
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||
{t("diary.shipLabel")} {selectedShip.shipName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -116,20 +202,20 @@ export default function FilterModal({
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={[styles.footer, themedStyles.footer]}>
|
||||
<TouchableOpacity
|
||||
style={styles.resetButton}
|
||||
style={[styles.resetButton, themedStyles.resetButton]}
|
||||
onPress={handleReset}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.resetButtonText}>Đặt lại</Text>
|
||||
<Text style={[styles.resetButtonText, themedStyles.resetButtonText]}>{t("diary.reset")}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.applyButton}
|
||||
style={[styles.applyButton, themedStyles.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>
|
||||
@@ -138,6 +224,7 @@ export default function FilterModal({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
@@ -145,7 +232,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "80%",
|
||||
@@ -165,7 +251,6 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F3F4F6",
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
@@ -173,7 +258,6 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
color: "#111827",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -189,13 +273,11 @@ const styles = StyleSheet.create({
|
||||
previewContainer: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
backgroundColor: "#F9FAFB",
|
||||
borderRadius: 12,
|
||||
},
|
||||
previewTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#6B7280",
|
||||
marginBottom: 12,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
@@ -204,7 +286,6 @@ const styles = StyleSheet.create({
|
||||
}),
|
||||
},
|
||||
filterTag: {
|
||||
backgroundColor: "#EFF6FF",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
@@ -213,7 +294,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
filterTagText: {
|
||||
fontSize: 14,
|
||||
color: "#3B82F6",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -225,11 +305,9 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#F3F4F6",
|
||||
},
|
||||
resetButton: {
|
||||
flex: 1,
|
||||
backgroundColor: "#F3F4F6",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
@@ -237,7 +315,6 @@ const styles = StyleSheet.create({
|
||||
resetButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#6B7280",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -246,7 +323,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
applyButton: {
|
||||
flex: 1,
|
||||
backgroundColor: "#3B82F6",
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
280
components/diary/ShipDropdown.tsx
Normal file
280
components/diary/ShipDropdown.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
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 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 { colors } = useThemeContext();
|
||||
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");
|
||||
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>{t("diary.shipDropdown.label")}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.selector, themedStyles.selector]}
|
||||
onPress={() => setIsOpen(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.selectorText, themedStyles.selectorText, !value && themedStyles.placeholder]}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
<Ionicons name="ellipsis-horizontal" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View
|
||||
style={[styles.modalContent, themedStyles.modalContent]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={[styles.searchContainer, themedStyles.searchContainer]}>
|
||||
<Ionicons
|
||||
name="search"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput, themedStyles.searchInput]}
|
||||
placeholder={t("diary.shipDropdown.searchPlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText("")}>
|
||||
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.optionsList}>
|
||||
{/* "All Ships" option */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.option,
|
||||
themedStyles.option,
|
||||
!value && themedStyles.selectedOption,
|
||||
]}
|
||||
onPress={() => handleSelect(null)}
|
||||
>
|
||||
<Text style={[styles.optionText, themedStyles.optionText]}>
|
||||
{t("diary.shipDropdown.allShips")}
|
||||
</Text>
|
||||
{!value && (
|
||||
<Ionicons name="checkmark" size={20} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Filtered ship options */}
|
||||
{filteredShips.length > 0 ? (
|
||||
filteredShips.map((ship) => (
|
||||
<TouchableOpacity
|
||||
key={ship.id}
|
||||
style={[
|
||||
styles.option,
|
||||
themedStyles.option,
|
||||
value?.id === ship.id && themedStyles.selectedOption,
|
||||
]}
|
||||
onPress={() => handleSelect(ship)}
|
||||
>
|
||||
<Text style={[styles.optionText, themedStyles.optionText]}>
|
||||
{ship.shipName}
|
||||
</Text>
|
||||
{value?.id === ship.id && (
|
||||
<Ionicons name="checkmark" size={20} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, themedStyles.emptyText]}>
|
||||
{t("diary.shipDropdown.noShipsFound")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -9,47 +9,64 @@ 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";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
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: "completed", label: "Hoàn thành" },
|
||||
{ value: "in-progress", label: "Đang hoạt động" },
|
||||
{ value: "quality-check", label: "Đã khởi tạo" },
|
||||
{ value: "cancelled", label: "Đã hủy" },
|
||||
];
|
||||
|
||||
export default function StatusDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: StatusDropdownProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
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);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
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 },
|
||||
option: { borderBottomColor: colors.separator },
|
||||
selectedOption: { backgroundColor: colors.backgroundSecondary },
|
||||
optionText: { color: colors.text },
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Trạng thái</Text>
|
||||
<Text style={[styles.label, themedStyles.label]}>{t("diary.statusDropdown.label")}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.selector}
|
||||
style={[styles.selector, themedStyles.selector]}
|
||||
onPress={() => setIsOpen(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.selectorText, !value && styles.placeholder]}>
|
||||
<Text style={[styles.selectorText, themedStyles.selectorText, !value && themedStyles.placeholder]}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<Ionicons name="ellipsis-horizontal" size={20} color="#6B7280" />
|
||||
<Ionicons name="ellipsis-horizontal" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
@@ -63,27 +80,28 @@ export default function StatusDropdown({
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={[styles.modalContent, themedStyles.modalContent]}>
|
||||
<ScrollView>
|
||||
{STATUS_OPTIONS.map((option, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.option,
|
||||
value === option.value && styles.selectedOption,
|
||||
themedStyles.option,
|
||||
value === option.value && themedStyles.selectedOption,
|
||||
]}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
value === option.value && styles.selectedOptionText,
|
||||
themedStyles.optionText,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{value === option.value && (
|
||||
<Ionicons name="checkmark" size={20} color="#3B82F6" />
|
||||
<Ionicons name="checkmark" size={20} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -102,7 +120,6 @@ const styles = StyleSheet.create({
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 8,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
@@ -114,25 +131,19 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderWidth: 1,
|
||||
borderColor: "#D1D5DB",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
selectorText: {
|
||||
fontSize: 16,
|
||||
color: "#111827",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
color: "#9CA3AF",
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
@@ -140,7 +151,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
width: "80%",
|
||||
maxHeight: "60%",
|
||||
@@ -161,23 +171,13 @@ const styles = StyleSheet.create({
|
||||
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",
|
||||
}),
|
||||
},
|
||||
selectedOptionText: {
|
||||
color: "#3B82F6",
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,18 +7,80 @@ 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";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
interface TripCardProps {
|
||||
trip: Trip;
|
||||
trip: Model.Trip;
|
||||
onPress?: () => void;
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onTeam?: () => void;
|
||||
onSend?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function TripCard({ trip, onPress }: TripCardProps) {
|
||||
const statusConfig = TRIP_STATUS_CONFIG[trip.status];
|
||||
export default function TripCard({
|
||||
trip,
|
||||
onPress,
|
||||
onView,
|
||||
onEdit,
|
||||
onTeam,
|
||||
onSend,
|
||||
onDelete,
|
||||
}: TripCardProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
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.trip_status === 0 || trip.trip_status === 1;
|
||||
const showSend = trip.trip_status === 0;
|
||||
const showDelete = trip.trip_status === 1;
|
||||
|
||||
const themedStyles = {
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: colors.separator,
|
||||
},
|
||||
actionText: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}>
|
||||
<View style={[styles.card, themedStyles.card]}>
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
@@ -28,8 +90,7 @@ export default function TripCard({ trip, onPress }: TripCardProps) {
|
||||
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, themedStyles.title]}>{trip.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
@@ -56,53 +117,111 @@ export default function TripCard({ trip, onPress }: TripCardProps) {
|
||||
{/* Info Grid */}
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.label}>Tàu</Text>
|
||||
<Text style={styles.value}>
|
||||
{trip.vessel} ({trip.vesselCode})
|
||||
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipName")}</Text>
|
||||
<Text style={[styles.value, themedStyles.value]}>
|
||||
{thingOfTrip?.metadata?.ship_name /* 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, themedStyles.label]}>{t("diary.tripCard.departure")}</Text>
|
||||
<Text style={[styles.value, themedStyles.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, themedStyles.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, themedStyles.value]}>
|
||||
{trip.arrival_time
|
||||
? dayjs(trip.arrival_time).format("DD/MM/YYYY HH:mm")
|
||||
: "-"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={[styles.divider, themedStyles.divider]} />
|
||||
<View style={styles.actionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onView}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="eye-outline" size={20} color={colors.textSecondary} />
|
||||
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.view")}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showEdit && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onEdit}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="create-outline" size={20} color={colors.textSecondary} />
|
||||
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.edit")}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onTeam}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="people-outline" size={20} color={colors.textSecondary} />
|
||||
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.team")}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showSend && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onSend}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="send-outline" size={20} color={colors.textSecondary} />
|
||||
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.send")}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={onDelete}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={colors.error} />
|
||||
<Text style={[styles.actionText, styles.deleteText]}>{t("diary.tripCard.delete")}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: "#F3F4F6",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: "row",
|
||||
@@ -110,13 +229,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
titleContainer: {
|
||||
marginLeft: 12,
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#111827",
|
||||
marginBottom: 2,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
@@ -124,19 +242,11 @@ 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,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
badgeText: {
|
||||
fontSize: 12,
|
||||
@@ -148,16 +258,16 @@ const styles = StyleSheet.create({
|
||||
}),
|
||||
},
|
||||
infoGrid: {
|
||||
gap: 12,
|
||||
gap: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: "#6B7280",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
@@ -166,16 +276,37 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: "#111827",
|
||||
fontWeight: "500",
|
||||
textAlign: "right",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
duration: {
|
||||
color: "#3B82F6",
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-around",
|
||||
alignItems: "center",
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 14,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
deleteText: {
|
||||
color: "#EF4444",
|
||||
},
|
||||
});
|
||||
|
||||
388
components/diary/addTripModal/AutoFillSection.tsx
Normal file
388
components/diary/addTripModal/AutoFillSection.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, themedStyles.container]}>
|
||||
<View style={styles.contentWrapper}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="flash" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.autoFill.title")}
|
||||
</Text>
|
||||
<Text style={[styles.description, themedStyles.description]}>
|
||||
{t("diary.autoFill.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, themedStyles.button]}
|
||||
onPress={() => setIsOpen(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.buttonText}>{t("diary.autoFill.selectShip")}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View
|
||||
style={[styles.modalContent, themedStyles.modalContent]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||
{t("diary.autoFill.modalTitle")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Input */}
|
||||
<View
|
||||
style={[styles.searchContainer, themedStyles.searchContainer]}
|
||||
>
|
||||
<Ionicons
|
||||
name="search"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput, themedStyles.searchInput]}
|
||||
placeholder={t("diary.searchShip")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText("")}>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
|
||||
{t("diary.autoFill.loading")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.optionsList}>
|
||||
{/* Filtered ship options */}
|
||||
{filteredShips.length > 0 ? (
|
||||
filteredShips.map((ship) => (
|
||||
<TouchableOpacity
|
||||
key={ship.id}
|
||||
style={[styles.option, themedStyles.option]}
|
||||
onPress={() => handleSelectShip(ship.id)}
|
||||
>
|
||||
<View style={styles.optionContent}>
|
||||
<Ionicons
|
||||
name="boat"
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
style={styles.optionIcon}
|
||||
/>
|
||||
<Text style={[styles.optionText, themedStyles.optionText]}>
|
||||
{ship.shipName}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, themedStyles.emptyText]}>
|
||||
{t("diary.noShipsFound")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
89
components/diary/addTripModal/BasicInfoInput.tsx
Normal file
89
components/diary/addTripModal/BasicInfoInput.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.fishingGroundCodes")}
|
||||
</Text>
|
||||
<Text style={[styles.subLabel, themedStyles.subLabel]}>
|
||||
{t("diary.fishingGroundCodesHint")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, themedStyles.input]}
|
||||
value={fishingGroundCodes}
|
||||
onChangeText={onChange}
|
||||
placeholder={t("diary.fishingGroundCodesPlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
225
components/diary/addTripModal/FishingGearList.tsx
Normal file
225
components/diary/addTripModal/FishingGearList.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
|
||||
{t("diary.fishingGearList")}
|
||||
</Text>
|
||||
|
||||
{/* Gear Items List */}
|
||||
{items.map((gear, index) => (
|
||||
<View key={gear.id} style={styles.gearRow}>
|
||||
{/* Name Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.gearName")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.nameInput, themedStyles.input]}
|
||||
value={gear.name}
|
||||
onChangeText={(value) => handleUpdateGear(gear.id, "name", value)}
|
||||
placeholder={t("diary.gearNamePlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Number Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.gearNumber")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput, themedStyles.input]}
|
||||
value={gear.number}
|
||||
onChangeText={(value) => handleUpdateGear(gear.id, "number", value)}
|
||||
placeholder={t("diary.gearNumberPlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDuplicateGear(gear)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="copy-outline" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveGear(gear.id)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, themedStyles.addButton]}
|
||||
onPress={handleAddGear}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colors.primary} />
|
||||
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
|
||||
{t("diary.addFishingGear")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
472
components/diary/addTripModal/MaterialCostList.tsx
Normal file
472
components/diary/addTripModal/MaterialCostList.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
|
||||
{t("diary.materialCostList")}
|
||||
</Text>
|
||||
|
||||
{/* Cost Items List */}
|
||||
{items.map((cost) => (
|
||||
<View key={cost.id} style={styles.costRow}>
|
||||
<View style={styles.formRow}>
|
||||
{/* Type Dropdown */}
|
||||
<View style={[styles.inputGroup, styles.typeGroup]}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.costType")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dropdown, themedStyles.dropdown]}
|
||||
onPress={() => setTypeDropdownVisible(cost.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dropdownText,
|
||||
themedStyles.dropdownText,
|
||||
!cost.type && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{getTypeLabel(cost.type)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Type Dropdown Modal */}
|
||||
<Modal
|
||||
visible={typeDropdownVisible === cost.id}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setTypeDropdownVisible(null)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalOverlay, themedStyles.modalOverlay]}
|
||||
activeOpacity={1}
|
||||
onPress={() => setTypeDropdownVisible(null)}
|
||||
>
|
||||
<View style={[styles.modalContent, themedStyles.modalContent]}>
|
||||
<ScrollView>
|
||||
{COST_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value}
|
||||
style={[styles.option, themedStyles.option]}
|
||||
onPress={() => {
|
||||
handleUpdateMaterial(cost.id, "type", type.value);
|
||||
setTypeDropdownVisible(null);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[styles.optionText, themedStyles.optionText]}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
{cost.type === type.value && (
|
||||
<Ionicons
|
||||
name="checkmark"
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
|
||||
{/* Amount Input */}
|
||||
<View style={[styles.inputGroup, styles.smallGroup]}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.amount")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.smallInput, themedStyles.input]}
|
||||
value={cost.amount.toString()}
|
||||
onChangeText={(value) =>
|
||||
handleUpdateMaterial(cost.id, "amount", Number(value) || 0)
|
||||
}
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Unit Input */}
|
||||
<View style={[styles.inputGroup, styles.smallGroup]}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.unit")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.smallInput, themedStyles.input]}
|
||||
value={cost.unit}
|
||||
onChangeText={(value) =>
|
||||
handleUpdateMaterial(cost.id, "unit", value)
|
||||
}
|
||||
placeholder={t("diary.unitPlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.formRow}>
|
||||
{/* Cost Per Unit Input */}
|
||||
<View style={[styles.inputGroup, styles.mediumGroup]}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.costPerUnit")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.mediumInput, themedStyles.input]}
|
||||
value={cost.cost_per_unit.toString()}
|
||||
onChangeText={(value) =>
|
||||
handleUpdateMaterial(
|
||||
cost.id,
|
||||
"cost_per_unit",
|
||||
Number(value) || 0
|
||||
)
|
||||
}
|
||||
placeholder="0"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Total Cost (Read-only, auto-calculated) */}
|
||||
<View style={[styles.inputGroup, styles.mediumGroup]}>
|
||||
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
|
||||
{t("diary.totalCost")}
|
||||
</Text>
|
||||
<View style={[styles.input, styles.mediumInput, themedStyles.input, styles.readOnlyInput]}>
|
||||
<Text style={[styles.readOnlyText, themedStyles.dropdownText]}>
|
||||
{formatCurrency(cost.total_cost)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDuplicateMaterial(cost)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="copy-outline" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveMaterial(cost.id)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="trash-outline"
|
||||
size={20}
|
||||
color={colors.error}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, themedStyles.addButton]}
|
||||
onPress={handleAddMaterial}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colors.primary} />
|
||||
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
|
||||
{t("diary.addMaterialCost")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
171
components/diary/addTripModal/PortSelector.tsx
Normal file
171
components/diary/addTripModal/PortSelector.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.portLabel")}
|
||||
</Text>
|
||||
<View style={styles.portContainer}>
|
||||
{/* Departure Port */}
|
||||
<View style={styles.portSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.departurePort")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.portSelector, themedStyles.portSelector]}
|
||||
onPress={handleSelectDeparturePort}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.portText,
|
||||
themedStyles.portText,
|
||||
!departurePortId && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{getPortDisplayName(departurePortId)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Arrival Port */}
|
||||
<View style={styles.portSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.arrivalPort")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.portSelector, themedStyles.portSelector]}
|
||||
onPress={handleSelectArrivalPort}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.portText,
|
||||
themedStyles.portText,
|
||||
!arrivalPortId && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{getPortDisplayName(arrivalPortId)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
293
components/diary/addTripModal/ShipSelector.tsx
Normal file
293
components/diary/addTripModal/ShipSelector.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.shipSelector")}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.selector, themedStyles.selector]}
|
||||
onPress={() => setIsOpen(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.selectorText,
|
||||
themedStyles.selectorText,
|
||||
!selectedShipId && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View
|
||||
style={[styles.modalContent, themedStyles.modalContent]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View
|
||||
style={[styles.searchContainer, themedStyles.searchContainer]}
|
||||
>
|
||||
<Ionicons
|
||||
name="search"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput, themedStyles.searchInput]}
|
||||
placeholder={t("diary.searchShip")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText("")}>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.optionsList}>
|
||||
{/* Filtered ship options */}
|
||||
{filteredShips.length > 0 ? (
|
||||
filteredShips.map((ship) => (
|
||||
<TouchableOpacity
|
||||
key={ship.id}
|
||||
style={[
|
||||
styles.option,
|
||||
themedStyles.option,
|
||||
selectedShipId === ship.id && themedStyles.selectedOption,
|
||||
]}
|
||||
onPress={() => handleSelect(ship.id)}
|
||||
>
|
||||
<Text style={[styles.optionText, themedStyles.optionText]}>
|
||||
{ship.shipName}
|
||||
</Text>
|
||||
{selectedShipId === ship.id && (
|
||||
<Ionicons
|
||||
name="checkmark"
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, themedStyles.emptyText]}>
|
||||
{t("diary.noShipsFound")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
289
components/diary/addTripModal/TripDurationPicker.tsx
Normal file
289
components/diary/addTripModal/TripDurationPicker.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.tripDuration")}
|
||||
</Text>
|
||||
<View style={styles.dateRangeContainer}>
|
||||
{/* Start Date */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.startDate")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!startDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{startDate ? formatDate(startDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* End Date */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={[styles.subLabel, themedStyles.placeholder]}>
|
||||
{t("diary.endDate")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dateInput, themedStyles.dateInput]}
|
||||
onPress={() => setShowEndPicker(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dateText,
|
||||
themedStyles.dateText,
|
||||
!endDate && themedStyles.placeholder,
|
||||
]}
|
||||
>
|
||||
{endDate ? formatDate(endDate) : t("diary.selectDate")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="calendar-outline"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Start Date Picker */}
|
||||
{showStartPicker && (
|
||||
<Modal transparent animationType="fade" visible={showStartPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
|
||||
{t("diary.selectStartDate")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={startDate || new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleStartDateChange}
|
||||
maximumDate={endDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* End Date Picker */}
|
||||
{showEndPicker && (
|
||||
<Modal transparent animationType="fade" visible={showEndPicker}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
|
||||
{t("diary.selectEndDate")}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||
<Text style={styles.doneButton}>{t("common.done")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
value={endDate || new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||
onChange={handleEndDateChange}
|
||||
minimumDate={startDate || undefined}
|
||||
themeVariant={colorScheme}
|
||||
textColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
72
components/diary/addTripModal/TripNameInput.tsx
Normal file
72
components/diary/addTripModal/TripNameInput.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.tripNameLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, themedStyles.input]}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={t("diary.tripNamePlaceholder")}
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
386
components/diary/addTripModal/index.tsx
Normal file
386
components/diary/addTripModal/index.tsx
Normal file
@@ -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<string>("");
|
||||
const [tripName, setTripName] = useState("");
|
||||
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
|
||||
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
|
||||
const [startDate, setStartDate] = useState<Date | null>(null);
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [departurePortId, setDeparturePortId] = useState<number>(1);
|
||||
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
|
||||
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // 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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modalContainer, themedStyles.modalContainer]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, themedStyles.header]}>
|
||||
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, themedStyles.title]}>
|
||||
{t("diary.addTrip")}
|
||||
</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Auto Fill Section */}
|
||||
<AutoFillSection onAutoFill={handleAutoFill} />
|
||||
|
||||
{/* Ship Selector */}
|
||||
<ShipSelector
|
||||
selectedShipId={selectedShipId}
|
||||
onChange={setSelectedShipId}
|
||||
/>
|
||||
|
||||
{/* Trip Name */}
|
||||
<TripNameInput value={tripName} onChange={setTripName} />
|
||||
|
||||
{/* Fishing Gear List */}
|
||||
<FishingGearList
|
||||
items={fishingGears}
|
||||
onChange={setFishingGears}
|
||||
/>
|
||||
|
||||
{/* Trip Cost List */}
|
||||
<MaterialCostList
|
||||
items={tripCosts}
|
||||
onChange={setTripCosts}
|
||||
/>
|
||||
|
||||
{/* Trip Duration */}
|
||||
<TripDurationPicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Port Selector */}
|
||||
<PortSelector
|
||||
departurePortId={departurePortId}
|
||||
arrivalPortId={arrivalPortId}
|
||||
onDeparturePortChange={setDeparturePortId}
|
||||
onArrivalPortChange={setArrivalPortId}
|
||||
/>
|
||||
|
||||
{/* Fishing Ground Codes */}
|
||||
<BasicInfoInput
|
||||
fishingGroundCodes={fishingGroundCodes}
|
||||
onChange={setFishingGroundCodes}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={[styles.footer, themedStyles.footer]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, themedStyles.cancelButton]}
|
||||
onPress={handleCancel}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, themedStyles.submitButton]}
|
||||
onPress={handleSubmit}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>
|
||||
{t("diary.createTrip")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1,70 +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: "quality-check",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
@@ -1,44 +1,93 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
|
||||
export type TripStatus =
|
||||
| "completed"
|
||||
| "in-progress"
|
||||
| "cancelled"
|
||||
| "quality-check";
|
||||
|
||||
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 = {
|
||||
completed: {
|
||||
0: {
|
||||
label: "Đã khởi tạo",
|
||||
bgColor: "#F3F4F6", // Gray background
|
||||
textColor: "#4B5563", // Gray text
|
||||
icon: "document-text",
|
||||
},
|
||||
1: {
|
||||
label: "Chờ duyệt",
|
||||
bgColor: "#FEF3C7", // Yellow background
|
||||
textColor: "#92400E", // Dark yellow text
|
||||
icon: "hourglass",
|
||||
},
|
||||
2: {
|
||||
label: "Đã duyệt",
|
||||
bgColor: "#E0E7FF", // Indigo background
|
||||
textColor: "#3730A3", // Dark indigo text
|
||||
icon: "checkmark-done",
|
||||
},
|
||||
3: {
|
||||
label: "Đang hoạt động",
|
||||
bgColor: "#DBEAFE", // Blue background
|
||||
textColor: "#1E40AF", // Dark blue text
|
||||
icon: "sync",
|
||||
},
|
||||
4: {
|
||||
label: "Hoàn thành",
|
||||
bgColor: "#D1FAE5", // Green background
|
||||
textColor: "#065F46", // Dark green text
|
||||
icon: "checkmark-circle",
|
||||
},
|
||||
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",
|
||||
},
|
||||
"in-progress": {
|
||||
label: "Đang diễn ra",
|
||||
bgColor: "#DBEAFE",
|
||||
textColor: "#1E40AF",
|
||||
icon: "time",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Đã hủy",
|
||||
5: {
|
||||
label: t("diary.statusDropdown.cancelled"),
|
||||
bgColor: "#FEE2E2",
|
||||
textColor: "#991B1B",
|
||||
icon: "close-circle",
|
||||
},
|
||||
"quality-check": {
|
||||
label: "Khảo sát địa chất",
|
||||
bgColor: "#D1FAE5",
|
||||
textColor: "#065F46",
|
||||
icon: "checkmark-circle",
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
143
components/map/AlarmList.tsx
Normal file
143
components/map/AlarmList.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { AlarmData } from "@/app/(tabs)";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { formatTimestamp } from "@/services/time_service";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback } from "react";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
|
||||
// ============ Types ============
|
||||
type AlarmType = "approaching" | "entered" | "fishing";
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: AlarmData;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
// ============ Config ============
|
||||
const ALARM_CONFIG: Record<
|
||||
AlarmType,
|
||||
{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
labelColor: string;
|
||||
}
|
||||
> = {
|
||||
entered: {
|
||||
icon: "warning",
|
||||
label: "Xâm nhập",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconBgColor: "bg-red-100",
|
||||
iconColor: "#DC2626",
|
||||
labelColor: "text-red-600",
|
||||
},
|
||||
approaching: {
|
||||
icon: "alert-circle",
|
||||
label: "Tiếp cận",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
iconBgColor: "bg-amber-100",
|
||||
iconColor: "#D97706",
|
||||
labelColor: "text-amber-600",
|
||||
},
|
||||
fishing: {
|
||||
icon: "fish",
|
||||
label: "Đánh bắt",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200",
|
||||
iconBgColor: "bg-orange-100",
|
||||
iconColor: "#EA580C",
|
||||
labelColor: "text-orange-600",
|
||||
},
|
||||
};
|
||||
|
||||
// ============ AlarmCard Component ============
|
||||
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||
const config = ALARM_CONFIG[alarm.type];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||
>
|
||||
<View className="flex-row items-start gap-3">
|
||||
{/* Icon Container */}
|
||||
<View
|
||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1">
|
||||
{/* Header: Ship name + Badge */}
|
||||
<View className="flex-row items-center justify-between mb-1">
|
||||
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||
{alarm.ship_name || alarm.thing_id}
|
||||
</ThemedText>
|
||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||
<ThemedText
|
||||
className={`text-xs font-semibold ${config.labelColor}`}
|
||||
>
|
||||
{config.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Zone Info */}
|
||||
<ThemedText className="text-xs text-gray-600 mb-2" numberOfLines={2}>
|
||||
{alarm.zone.message || alarm.zone.zone_name}
|
||||
</ThemedText>
|
||||
|
||||
{/* Footer: Zone ID + Time */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||
<ThemedText className="text-xs text-gray-500">
|
||||
{formatTimestamp(alarm.zone.gps_time)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ Main Component ============
|
||||
interface AlarmListProps {
|
||||
data: AlarmData[];
|
||||
onPress?: (alarm: AlarmData) => void;
|
||||
}
|
||||
|
||||
export default function AlarmList({ data, onPress }: AlarmListProps) {
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: AlarmData }) => (
|
||||
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||
),
|
||||
[onPress]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
133
components/map/CircleWithLabel.tsx
Normal file
133
components/map/CircleWithLabel.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ANDROID_PLATFORM } from "@/constants";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import React, { useRef } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { Circle, MapMarker, Marker } from "react-native-maps";
|
||||
|
||||
export interface CircleWithLabelProps {
|
||||
center: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
radius: number;
|
||||
label?: string;
|
||||
content?: string;
|
||||
fillColor?: string;
|
||||
strokeColor?: string;
|
||||
strokeWidth?: number;
|
||||
zIndex?: number;
|
||||
zoomLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component render Circle kèm Label/Text ở giữa
|
||||
*/
|
||||
export const CircleWithLabel: React.FC<CircleWithLabelProps> = ({
|
||||
center,
|
||||
radius,
|
||||
label,
|
||||
content,
|
||||
fillColor = "rgba(220, 20, 60, 0.6)",
|
||||
strokeColor = "rgba(220, 20, 60, 0.8)",
|
||||
strokeWidth = 2,
|
||||
zIndex = 50,
|
||||
zoomLevel = 10,
|
||||
}) => {
|
||||
if (!center) {
|
||||
return null;
|
||||
}
|
||||
const platform = usePlatform();
|
||||
const markerRef = useRef<MapMarker>(null);
|
||||
|
||||
// Tính font size dựa trên zoom level
|
||||
// Zoom càng thấp (xa ra) thì font size càng nhỏ
|
||||
const calculateFontSize = (baseSize: number) => {
|
||||
const baseZoom = 10;
|
||||
// Giảm scale factor để text không quá to khi zoom out
|
||||
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
|
||||
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
|
||||
};
|
||||
|
||||
const labelFontSize = calculateFontSize(12);
|
||||
const contentFontSize = calculateFontSize(10);
|
||||
|
||||
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
|
||||
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Circle
|
||||
center={center}
|
||||
radius={radius}
|
||||
fillColor={fillColor}
|
||||
strokeColor={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
zIndex={zIndex}
|
||||
/>
|
||||
{label && (
|
||||
<Marker
|
||||
ref={markerRef}
|
||||
coordinate={center}
|
||||
zIndex={50}
|
||||
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||
>
|
||||
<View style={styles.markerContainer}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingHorizontal: 5 * paddingScale,
|
||||
paddingVertical: 5 * paddingScale,
|
||||
minWidth: 80,
|
||||
maxWidth: 150 * minWidthScale,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.labelText, { fontSize: labelFontSize }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{content && (
|
||||
<Text
|
||||
style={[
|
||||
styles.contentText,
|
||||
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Marker>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
markerContainer: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
labelText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
letterSpacing: 0.3,
|
||||
textAlign: "center",
|
||||
},
|
||||
contentText: {
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.2,
|
||||
textAlign: "center",
|
||||
opacity: 0.95,
|
||||
},
|
||||
});
|
||||
110
components/map/MarkerCustom.tsx
Normal file
110
components/map/MarkerCustom.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { getShipIcon } from "@/services/map_service";
|
||||
import React from "react";
|
||||
import { Animated, Image, StyleSheet, View } from "react-native";
|
||||
import { Marker } from "react-native-maps";
|
||||
|
||||
interface MarkerCustomProps {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
shipName?: string;
|
||||
description?: string;
|
||||
stateLevel?: number;
|
||||
isFishing?: boolean;
|
||||
heading?: number;
|
||||
zIndex?: number;
|
||||
anchor?: { x: number; y: number };
|
||||
tracksViewChanges?: boolean;
|
||||
identifier?: string;
|
||||
animated?: {
|
||||
scale: Animated.Value;
|
||||
opacity: Animated.Value;
|
||||
};
|
||||
}
|
||||
|
||||
export const MarkerCustom: React.FC<MarkerCustomProps> = ({
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
shipName,
|
||||
description,
|
||||
stateLevel = 0,
|
||||
isFishing = false,
|
||||
heading = 0,
|
||||
zIndex = 50,
|
||||
anchor = { x: 0.5, y: 0.5 },
|
||||
tracksViewChanges = false,
|
||||
identifier,
|
||||
animated,
|
||||
}) => {
|
||||
const uniqueKey =
|
||||
id || `marker-${latitude.toFixed(6)}-${longitude.toFixed(6)}`;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={uniqueKey}
|
||||
coordinate={{
|
||||
latitude,
|
||||
longitude,
|
||||
}}
|
||||
zIndex={zIndex}
|
||||
anchor={anchor}
|
||||
title={shipName}
|
||||
description={description}
|
||||
tracksViewChanges={tracksViewChanges}
|
||||
identifier={identifier || uniqueKey}
|
||||
>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<View style={styles.pingContainer}>
|
||||
{animated && stateLevel === 3 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.pingCircle,
|
||||
{
|
||||
transform: [{ scale: animated.scale }],
|
||||
opacity: animated.opacity,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
source={(() => {
|
||||
const icon = getShipIcon(stateLevel, isFishing);
|
||||
return typeof icon === "string" ? { uri: icon } : icon;
|
||||
})()}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
transform: [
|
||||
{
|
||||
rotate: `${
|
||||
typeof heading === "number" && !isNaN(heading) ? heading : 0
|
||||
}deg`,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Marker>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkerCustom;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pingContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
pingCircle: {
|
||||
position: "absolute",
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#ED3F27",
|
||||
},
|
||||
});
|
||||
230
components/map/ZoneInMap.tsx
Normal file
230
components/map/ZoneInMap.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { BanzoneWithAlarm } from "@/app/(tabs)";
|
||||
import {
|
||||
convertWKTLineStringToLatLngArray,
|
||||
convertWKTPointToLatLng,
|
||||
convertWKTtoLatLngString,
|
||||
} from "@/utils/geom";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { CircleWithLabel } from "./CircleWithLabel";
|
||||
import { MarkerCustom } from "./MarkerCustom";
|
||||
import { PolygonWithLabel } from "./PolygonWithLabel";
|
||||
import { PolylineWithLabel } from "./PolylineWithLabel";
|
||||
|
||||
import MapView from "react-native-maps";
|
||||
|
||||
interface ZoneInMapProps {
|
||||
banzones: BanzoneWithAlarm[];
|
||||
mapRef?: React.RefObject<MapView | null>;
|
||||
}
|
||||
|
||||
// Helper function to parse zone geometry
|
||||
const parseZoneGeometry = (geometryString: string | undefined) => {
|
||||
if (!geometryString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const geometry: Model.Geom = JSON.parse(geometryString);
|
||||
return geometry;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse geometry:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ZoneInMap = (data: ZoneInMapProps) => {
|
||||
const { banzones, mapRef } = data;
|
||||
|
||||
// Auto-focus camera to first alarm location when banzones change
|
||||
useEffect(() => {
|
||||
if (mapRef?.current && banzones.length > 0) {
|
||||
const firstAlarm = banzones[0].alarms;
|
||||
if (
|
||||
firstAlarm.zone.lat !== undefined &&
|
||||
firstAlarm.zone.lon !== undefined
|
||||
) {
|
||||
setTimeout(() => {
|
||||
mapRef.current?.animateToRegion(
|
||||
{
|
||||
latitude: firstAlarm.zone.lat as number,
|
||||
longitude: firstAlarm.zone.lon as number,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
},
|
||||
1000
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [banzones, mapRef]);
|
||||
|
||||
// Parse and render all banzones with their ship markers
|
||||
const allElements = useMemo(() => {
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
console.log("ZoneInMap - banzones received:", banzones);
|
||||
|
||||
banzones.forEach((banzone, banzoneIndex) => {
|
||||
const { zone, alarms } = banzone;
|
||||
|
||||
console.log(`Processing banzone ${banzoneIndex}:`, {
|
||||
zone: zone,
|
||||
alarms: alarms,
|
||||
geometry: zone?.geometry,
|
||||
});
|
||||
|
||||
// Parse geometry with error handling
|
||||
const geometry = parseZoneGeometry(zone?.geometry);
|
||||
if (!geometry) {
|
||||
console.warn(`No geometry for zone ${banzoneIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { geom_type, geom_lines, geom_poly, geom_point, geom_radius } =
|
||||
geometry;
|
||||
|
||||
console.log(`Parsed geometry for zone ${banzoneIndex}:`, {
|
||||
geom_type,
|
||||
geom_lines: geom_lines?.substring(0, 100) + "...",
|
||||
geom_poly: geom_poly?.substring(0, 100) + "...",
|
||||
geom_point,
|
||||
geom_radius,
|
||||
});
|
||||
|
||||
try {
|
||||
if (geom_type === 2) {
|
||||
// LINESTRING - use PolylineWithLabel
|
||||
// console.log(`Processing LINESTRING for zone ${banzoneIndex}`);
|
||||
const coordinates = convertWKTLineStringToLatLngArray(
|
||||
geom_lines || ""
|
||||
);
|
||||
// console.log(`Converted coordinates:`, coordinates);
|
||||
if (coordinates.length > 0) {
|
||||
elements.push(
|
||||
<PolylineWithLabel
|
||||
key={`line-${zone?.id || banzoneIndex}`}
|
||||
coordinates={coordinates.map((coord) => ({
|
||||
latitude: coord[0],
|
||||
longitude: coord[1],
|
||||
}))}
|
||||
label={zone?.name || alarms.zone.zone_name || ""}
|
||||
content={alarms.zone.message || ""}
|
||||
/>
|
||||
);
|
||||
// console.log(`Added PolylineWithLabel for zone ${banzoneIndex}`);
|
||||
}
|
||||
} else if (geom_type === 1) {
|
||||
// MULTIPOLYGON - check both geom_poly and geom_lines
|
||||
// console.log(`Processing MULTIPOLYGON for zone ${banzoneIndex}`);
|
||||
``;
|
||||
// First check if we have actual polygon data
|
||||
if (geom_poly && geom_poly.trim() !== "") {
|
||||
const polygons = convertWKTtoLatLngString(geom_poly);
|
||||
// console.log(`Converted polygons from geom_poly:`, polygons);
|
||||
polygons.forEach((polygon, polygonIndex) => {
|
||||
if (polygon.length > 0) {
|
||||
elements.push(
|
||||
<PolygonWithLabel
|
||||
key={`polygon-${zone?.id || banzoneIndex}-${polygonIndex}`}
|
||||
coordinates={polygon.map((coord) => ({
|
||||
latitude: coord[0],
|
||||
longitude: coord[1],
|
||||
}))}
|
||||
label={zone?.name || alarms.zone.zone_name || ""}
|
||||
content={alarms.zone.message || ""}
|
||||
/>
|
||||
);
|
||||
// console.log(
|
||||
// `Added PolygonWithLabel for zone ${banzoneIndex}-${polygonIndex}`
|
||||
// );
|
||||
}
|
||||
});
|
||||
} else if (geom_lines && geom_lines.trim() !== "") {
|
||||
// If no polygon data, treat geom_lines as a line (data inconsistency fix)
|
||||
// console.log(
|
||||
// `No polygon data, processing as LINESTRING from geom_lines`
|
||||
// );
|
||||
const coordinates = convertWKTLineStringToLatLngArray(geom_lines);
|
||||
console.log(`Converted coordinates from geom_lines:`, coordinates);
|
||||
if (coordinates.length > 0) {
|
||||
elements.push(
|
||||
<PolylineWithLabel
|
||||
key={`line-${zone?.id || banzoneIndex}`}
|
||||
coordinates={coordinates.map((coord) => ({
|
||||
latitude: coord[0],
|
||||
longitude: coord[1],
|
||||
}))}
|
||||
label={zone?.name || alarms.zone.zone_name || ""}
|
||||
content={alarms.zone.message || ""}
|
||||
/>
|
||||
);
|
||||
// console.log(
|
||||
// `Added PolylineWithLabel for zone ${banzoneIndex} (from geom_lines)`
|
||||
// );
|
||||
}
|
||||
} else {
|
||||
// console.warn(`No valid geometry data for zone ${banzoneIndex}`);
|
||||
}
|
||||
} else if (geom_type === 3) {
|
||||
// POINT/CIRCLE - use Circle
|
||||
// console.log(`Processing POINT/CIRCLE for zone ${banzoneIndex}`);
|
||||
const point = convertWKTPointToLatLng(geom_point || "");
|
||||
// console.log(`Converted point:`, point, `radius:`, geom_radius);
|
||||
if (point && geom_radius) {
|
||||
elements.push(
|
||||
<CircleWithLabel
|
||||
key={`circle-${zone?.id || banzoneIndex}`}
|
||||
center={{
|
||||
latitude: point[1], // Note: convertWKTPointToLatLng returns [lng, lat]
|
||||
longitude: point[0],
|
||||
}}
|
||||
radius={geom_radius}
|
||||
label={zone?.name || alarms.zone.zone_name || ""}
|
||||
content={alarms.zone.message || ""}
|
||||
/>
|
||||
);
|
||||
// console.log(`Added Circle for zone ${banzoneIndex}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Unknown geom_type ${geom_type} for zone ${banzoneIndex}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Error processing zone geometry for zone",
|
||||
zone?.id,
|
||||
":",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Ship marker for the alarm location
|
||||
if (alarms.zone.lat && alarms.zone.lon) {
|
||||
elements.push(
|
||||
<MarkerCustom
|
||||
key={`ship-${alarms.thing_id || banzoneIndex}`}
|
||||
id={`ship-${alarms.thing_id || banzoneIndex}`}
|
||||
latitude={alarms.zone.lat}
|
||||
longitude={alarms.zone.lon}
|
||||
shipName={alarms.ship_name || "Tàu không xác định"}
|
||||
description={
|
||||
alarms.zone.gps_time
|
||||
? new Date(alarms.zone.gps_time * 1000).toLocaleString()
|
||||
: ""
|
||||
}
|
||||
heading={alarms.zone.h}
|
||||
zIndex={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// console.log(`Total elements rendered: ${elements.length}`);
|
||||
return elements;
|
||||
}, [banzones]);
|
||||
|
||||
return <>{allElements}</>;
|
||||
};
|
||||
|
||||
export default ZoneInMap;
|
||||
@@ -31,7 +31,7 @@ const MAPPING = {
|
||||
xmark: "close",
|
||||
pencil: "edit",
|
||||
trash: "delete",
|
||||
"square.stack.3d.up": "layers",
|
||||
"square.stack.3d.up.fill": "layers",
|
||||
"bell.fill": "notifications",
|
||||
} as IconMapping;
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -53,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";
|
||||
|
||||
@@ -4,3 +4,7 @@ import { API_GET_ALL_BANZONES } from "@/constants";
|
||||
export async function queryBanzones() {
|
||||
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
||||
}
|
||||
|
||||
export async function queryBanzoneById(zoneId: string) {
|
||||
return api.get<Model.Zone>(`${API_GET_ALL_BANZONES}/${zoneId}`);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@ import { api } from "@/config";
|
||||
import {
|
||||
API_GET_TRIP,
|
||||
API_HAUL_HANDLE,
|
||||
API_POST_TRIPSLIST,
|
||||
API_UPDATE_FISHING_LOGS,
|
||||
API_UPDATE_TRIP_STATUS,
|
||||
API_GET_LAST_TRIP,
|
||||
} from "@/constants";
|
||||
|
||||
export async function queryTrip() {
|
||||
return api.get<Model.Trip>(API_GET_TRIP);
|
||||
}
|
||||
|
||||
export async function queryLastTrip(thingId: string) {
|
||||
return api.get<Model.Trip>(`${API_GET_LAST_TRIP}/${thingId}`);
|
||||
}
|
||||
|
||||
export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
|
||||
return api.put(API_UPDATE_TRIP_STATUS, body);
|
||||
}
|
||||
@@ -21,3 +27,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);
|
||||
}
|
||||
|
||||
39
controller/typings.d.ts
vendored
39
controller/typings.d.ts
vendored
@@ -60,7 +60,9 @@ declare namespace Model {
|
||||
conditions?: Condition[];
|
||||
enabled?: boolean;
|
||||
updated_at?: Date;
|
||||
geom?: Geom;
|
||||
geometry?: string;
|
||||
description?: string;
|
||||
province_code?: string;
|
||||
}
|
||||
|
||||
interface Condition {
|
||||
@@ -93,7 +95,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;
|
||||
@@ -213,12 +245,13 @@ declare namespace Model {
|
||||
vn_law: boolean;
|
||||
}
|
||||
|
||||
// Seagateway Owner Appp
|
||||
// Seagateway Owner App
|
||||
// Thing
|
||||
interface SearchThingBody {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
sort?: "asc" | "desc";
|
||||
dir?: "asc" | "desc";
|
||||
name?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
107
locales/en.json
107
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",
|
||||
@@ -60,6 +61,112 @@
|
||||
"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",
|
||||
"loadingMore": "Loading more...",
|
||||
"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",
|
||||
"shipName": "Ship Name",
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
"infoTrip": "Trip Information",
|
||||
"createNewTrip": "Create New Trip",
|
||||
|
||||
107
locales/vi.json
107
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",
|
||||
@@ -60,6 +61,112 @@
|
||||
"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",
|
||||
"loadingMore": "Đang tải thêm...",
|
||||
"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",
|
||||
"shipName": "Tên 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ỷ"
|
||||
},
|
||||
"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": {
|
||||
"infoTrip": "Thông Tin Chuyến Đi",
|
||||
"createNewTrip": "Tạo chuyến mới",
|
||||
|
||||
@@ -41,3 +41,8 @@ export function formatRelativeTime(unixTime: number): string {
|
||||
if (diffMonths < 12) return `${diffMonths} tháng trước`;
|
||||
return `${diffYears} năm trước`;
|
||||
}
|
||||
|
||||
export const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
|
||||
31
state/use-thing.ts
Normal file
31
state/use-thing.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { querySearchThings } from "@/controller/DeviceController";
|
||||
import { create } from "zustand";
|
||||
|
||||
type ThingState = {
|
||||
things: Model.Thing[] | null;
|
||||
getThings: (body: Model.SearchThingBody) => Promise<void>;
|
||||
error: string | null;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const useThings = create<ThingState>((set) => ({
|
||||
things: null,
|
||||
getThings: async (body: Model.SearchThingBody) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await querySearchThings(body);
|
||||
console.log("Things fetching API: ", response.data.things?.length);
|
||||
|
||||
set({ things: response.data.things ?? [], loading: false });
|
||||
} catch (error) {
|
||||
console.error("Error when fetch things: ", error);
|
||||
set({
|
||||
error: "Failed to fetch things data",
|
||||
loading: false,
|
||||
things: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
error: null,
|
||||
loading: false,
|
||||
}));
|
||||
31
state/use-tripslist.ts
Normal file
31
state/use-tripslist.ts
Normal 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,
|
||||
}));
|
||||
Reference in New Issue
Block a user