Compare commits
11 Commits
47e9bac0f9
...
tunztunzz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4318fed4 | ||
| 7c3497d159 | |||
| 0e1332f433 | |||
| 347bd1a7c1 | |||
|
|
695066a5e7 | ||
| c47d9ad14c | |||
| e405a0bcfa | |||
| 0672f8adf9 | |||
|
|
4d60ce279e | ||
|
|
42028eafc3 | ||
|
|
22a3b591c6 |
1494
ReactQuery_Axios.md
Normal file
1494
ReactQuery_Axios.md
Normal file
File diff suppressed because it is too large
Load Diff
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 segments = useSegments() as string[];
|
||||||
const prev = useRef<string | null>(null);
|
const prev = useRef<string | null>(null);
|
||||||
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||||
const { t, locale } = useI18n();
|
const { t } = useI18n();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prev.current !== currentSegment) {
|
if (prev.current !== currentSegment) {
|
||||||
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
|
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
|
||||||
@@ -60,7 +60,11 @@ export default function TabLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: t("navigation.manager"),
|
title: t("navigation.manager"),
|
||||||
tabBarIcon: ({ color }) => (
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Platform, ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import SearchBar from "@/components/diary/SearchBar";
|
|
||||||
import FilterButton from "@/components/diary/FilterButton";
|
import FilterButton from "@/components/diary/FilterButton";
|
||||||
import TripCard from "@/components/diary/TripCard";
|
import TripCard from "@/components/diary/TripCard";
|
||||||
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
|
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() {
|
export default function diary() {
|
||||||
const [searchText, setSearchText] = useState("");
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||||
|
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||||
const [filters, setFilters] = useState<FilterValues>({
|
const [filters, setFilters] = useState<FilterValues>({
|
||||||
status: null,
|
status: null,
|
||||||
startDate: null,
|
startDate: null,
|
||||||
endDate: null,
|
endDate: null,
|
||||||
|
selectedShip: null, // Tàu được chọn
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter trips based on search text and filters
|
// State for pagination
|
||||||
const filteredTrips = MOCK_TRIPS.filter((trip) => {
|
const [allTrips, setAllTrips] = useState<any[]>([]);
|
||||||
// Search filter
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
if (searchText) {
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const searchLower = searchText.toLowerCase();
|
const isInitialLoad = useRef(true);
|
||||||
const matchesSearch =
|
|
||||||
trip.title.toLowerCase().includes(searchLower) ||
|
|
||||||
trip.code.toLowerCase().includes(searchLower) ||
|
|
||||||
trip.vessel.toLowerCase().includes(searchLower) ||
|
|
||||||
trip.vesselCode.toLowerCase().includes(searchLower);
|
|
||||||
|
|
||||||
if (!matchesSearch) return false;
|
// Body call API things (đang fix cứng)
|
||||||
}
|
const payloadThings: Model.SearchThingBody = {
|
||||||
|
offset: 0,
|
||||||
// Status filter
|
limit: 200,
|
||||||
if (filters.status && trip.status !== filters.status) {
|
order: "name",
|
||||||
return false;
|
dir: "asc",
|
||||||
}
|
metadata: {
|
||||||
|
not_empty: "ship_name, ship_reg_number",
|
||||||
// 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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 = () => {
|
const handleFilter = () => {
|
||||||
setShowFilterModal(true);
|
setShowFilterModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyFilters = (newFilters: FilterValues) => {
|
const handleApplyFilters = (newFilters: FilterValues) => {
|
||||||
setFilters(newFilters);
|
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) => {
|
const handleTripPress = (tripId: string) => {
|
||||||
// TODO: Navigate to trip detail
|
// TODO: Navigate to trip detail
|
||||||
console.log("Trip pressed:", tripId);
|
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 (
|
||||||
|
<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 (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text style={styles.titleText}>Nhật ký chuyến đi</Text>
|
<Text style={[styles.titleText, themedStyles.titleText]}>{t("diary.title")}</Text>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Filter & Add Button Row */}
|
||||||
<SearchBar onSearch={handleSearch} style={{marginBottom: 10}}/>
|
<View style={styles.actionRow}>
|
||||||
|
<FilterButton
|
||||||
{/* Filter Button */}
|
onPress={handleFilter}
|
||||||
<FilterButton onPress={handleFilter} />
|
isFiltered={
|
||||||
|
filters.status !== null ||
|
||||||
{/* Trip Count & Add Button */}
|
filters.startDate !== null ||
|
||||||
<View style={styles.headerRow}>
|
filters.endDate !== null ||
|
||||||
<Text style={styles.countText}>
|
filters.selectedShip !== null
|
||||||
Danh sách chuyến đi ({filteredTrips.length})
|
}
|
||||||
</Text>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.addButton}
|
style={[styles.addButton, themedStyles.addButton]}
|
||||||
onPress={() => console.log("Add trip")}
|
onPress={() => setShowAddTripModal(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={20} color="#FFFFFF" />
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Trip List */}
|
{/* Trip Count */}
|
||||||
<ScrollView
|
<Text style={[styles.countText, themedStyles.countText]}>
|
||||||
style={styles.scrollView}
|
{t("diary.tripListCount", { count: tripsList?.total || 0 })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Trip List with FlatList */}
|
||||||
|
<FlatList
|
||||||
|
data={allTrips}
|
||||||
|
renderItem={renderTripItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
onEndReached={handleLoadMore}
|
||||||
{filteredTrips.map((trip) => (
|
onEndReachedThreshold={0.5}
|
||||||
<TripCard
|
ListFooterComponent={renderFooter}
|
||||||
key={trip.id}
|
ListEmptyComponent={renderEmpty}
|
||||||
trip={trip}
|
removeClippedSubviews={true}
|
||||||
onPress={() => handleTripPress(trip.id)}
|
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>
|
</View>
|
||||||
|
|
||||||
{/* Filter Modal */}
|
{/* Filter Modal */}
|
||||||
@@ -130,6 +326,12 @@ export default function diary() {
|
|||||||
onClose={() => setShowFilterModal(false)}
|
onClose={() => setShowFilterModal(false)}
|
||||||
onApply={handleApplyFilters}
|
onApply={handleApplyFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Trip Modal */}
|
||||||
|
<AddTripModal
|
||||||
|
visible={showAddTripModal}
|
||||||
|
onClose={() => setShowAddTripModal(false)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,24 +339,29 @@ export default function diary() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
safeArea: {
|
safeArea: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#F9FAFB",
|
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 16,
|
padding: 10,
|
||||||
},
|
},
|
||||||
titleText: {
|
titleText: {
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
lineHeight: 36,
|
lineHeight: 36,
|
||||||
marginBottom: 20,
|
marginBottom: 10,
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
headerRow: {
|
headerRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -165,17 +372,16 @@ const styles = StyleSheet.create({
|
|||||||
countText: {
|
countText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#374151",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
addButton: {
|
addButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#3B82F6",
|
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -191,9 +397,6 @@ const styles = StyleSheet.create({
|
|||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
@@ -204,7 +407,21 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 16,
|
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({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
|
|||||||
1075
app/(tabs)/index.tsx
1075
app/(tabs)/index.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,314 @@
|
|||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { AlarmCard } from "@/components/alarm/AlarmCard";
|
||||||
|
import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { queryAlarms } from "@/controller/AlarmController";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
FlatList,
|
||||||
|
LayoutAnimation,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function warning() {
|
const PAGE_SIZE = 2;
|
||||||
|
|
||||||
|
const WarningScreen = () => {
|
||||||
|
const [defaultAlarmParams, setDefaultAlarmParams] =
|
||||||
|
useState<Model.AlarmPayload>({
|
||||||
|
offset: 0,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
order: "time",
|
||||||
|
dir: "desc",
|
||||||
|
});
|
||||||
|
const [alarms, setAlarms] = useState<Model.Alarm[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [isShowSearchForm, setIsShowSearchForm] = useState(false);
|
||||||
|
const [formOpacity] = useState(new Animated.Value(0));
|
||||||
|
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
|
const hasFilters = useMemo(() => {
|
||||||
|
return Boolean(
|
||||||
|
(defaultAlarmParams as any)?.name ||
|
||||||
|
((defaultAlarmParams as any)?.level !== undefined &&
|
||||||
|
(defaultAlarmParams as any).level !== 0) ||
|
||||||
|
(defaultAlarmParams as any)?.confirmed !== undefined
|
||||||
|
);
|
||||||
|
}, [defaultAlarmParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAlarmsData(0, false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isShowSearchForm) {
|
||||||
|
// Reset opacity to 0, then animate to 1
|
||||||
|
formOpacity.setValue(0);
|
||||||
|
Animated.timing(formOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
formOpacity.setValue(0);
|
||||||
|
}
|
||||||
|
}, [isShowSearchForm, formOpacity]);
|
||||||
|
|
||||||
|
const getAlarmsData = async (
|
||||||
|
nextOffset = 0,
|
||||||
|
append = false,
|
||||||
|
paramsOverride?: Model.AlarmPayload
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (append) setIsLoadingMore(true);
|
||||||
|
else setLoading(true);
|
||||||
|
// console.log("Call alarm with offset: ", nextOffset);
|
||||||
|
const usedParams = paramsOverride ?? defaultAlarmParams;
|
||||||
|
// console.log("params: ", usedParams);
|
||||||
|
|
||||||
|
const resp = await queryAlarms({
|
||||||
|
...usedParams,
|
||||||
|
offset: nextOffset,
|
||||||
|
});
|
||||||
|
const slice = resp.data?.alarms ?? [];
|
||||||
|
|
||||||
|
setAlarms((prev) => (append ? [...prev, ...slice] : slice));
|
||||||
|
setOffset(nextOffset);
|
||||||
|
setHasMore(nextOffset + PAGE_SIZE < resp.data?.total!);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cannot get Alarm Data: ", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlarmReload = useCallback((onReload: boolean) => {
|
||||||
|
if (onReload) {
|
||||||
|
getAlarmsData(0, false, undefined);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderAlarmCard = useCallback(
|
||||||
|
({ item }: { item: Model.Alarm }) => (
|
||||||
|
<AlarmCard alarm={item} onReload={handleAlarmReload} />
|
||||||
|
),
|
||||||
|
[handleAlarmReload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback(
|
||||||
|
(item: Model.Alarm, index: number) =>
|
||||||
|
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
const nextOffset = offset + PAGE_SIZE;
|
||||||
|
getAlarmsData(nextOffset, true);
|
||||||
|
}, [isLoadingMore, hasMore, offset]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setRefreshing(true);
|
||||||
|
getAlarmsData(0, false, undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSearch = useCallback(
|
||||||
|
(values: { name?: string; level?: number; confirmed?: boolean }) => {
|
||||||
|
const mapped = {
|
||||||
|
offset: 0,
|
||||||
|
limit: defaultAlarmParams.limit,
|
||||||
|
order: defaultAlarmParams.order,
|
||||||
|
dir: defaultAlarmParams.dir,
|
||||||
|
...(values.name && { name: values.name }),
|
||||||
|
...(values.level && values.level !== 0 && { level: values.level }),
|
||||||
|
...(values.confirmed !== undefined && { confirmed: values.confirmed }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setDefaultAlarmParams(mapped);
|
||||||
|
// Call getAlarmsData with the mapped params directly so the
|
||||||
|
// request uses the updated params immediately (setState is async)
|
||||||
|
getAlarmsData(0, false, mapped);
|
||||||
|
toggleSearchForm();
|
||||||
|
},
|
||||||
|
[defaultAlarmParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSearchForm = useCallback(() => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShowSearchForm) {
|
||||||
|
// Hide form
|
||||||
|
Animated.timing(formOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => {
|
||||||
|
setIsShowSearchForm(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Show form
|
||||||
|
setIsShowSearchForm(true);
|
||||||
|
Animated.timing(formOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}, [isShowSearchForm, formOpacity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
<ThemedView style={styles.content}>
|
||||||
<View style={styles.container}>
|
{/* Header */}
|
||||||
<Text style={styles.titleText}>Cảnh báo</Text>
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View style={styles.badgeContainer}>
|
||||||
|
<TouchableOpacity onPress={toggleSearchForm}>
|
||||||
|
<Ionicons
|
||||||
|
size={20}
|
||||||
|
name="filter-outline"
|
||||||
|
color={hasFilters ? colors.primary : colors.text}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
|
{/* Search Form */}
|
||||||
|
{isShowSearchForm && (
|
||||||
|
<Animated.View style={{ opacity: formOpacity, zIndex: 100 }}>
|
||||||
|
<AlarmSearchForm
|
||||||
|
initialValue={{
|
||||||
|
name: defaultAlarmParams.name || "",
|
||||||
|
level: defaultAlarmParams.level || 0,
|
||||||
|
confirmed: defaultAlarmParams.confirmed,
|
||||||
|
}}
|
||||||
|
onSubmit={onSearch}
|
||||||
|
onReset={toggleSearchForm}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alarm List */}
|
||||||
|
{alarms.length > 0 ? (
|
||||||
|
<FlatList
|
||||||
|
data={alarms}
|
||||||
|
renderItem={renderAlarmCard}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
ListFooterComponent={
|
||||||
|
isLoadingMore ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<ActivityIndicator size="small" color="#dc2626" />
|
||||||
|
<ThemedText style={styles.footerText}>Đang tải...</ThemedText>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="shield-checkmark" size={48} color="#16a34a" />
|
||||||
|
<ThemedText style={styles.emptyText}>
|
||||||
|
Không có cảnh báo nào
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ThemedView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default WarningScreen;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
container: {
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: 15,
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#e5e7eb",
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
titleText: {
|
titleText: {
|
||||||
fontSize: 32,
|
fontSize: 24,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
lineHeight: 40,
|
},
|
||||||
marginBottom: 30,
|
badgeContainer: {
|
||||||
fontFamily: Platform.select({
|
// backgroundColor: "#dc2626",
|
||||||
ios: "System",
|
paddingHorizontal: 12,
|
||||||
android: "Roboto",
|
paddingVertical: 6,
|
||||||
default: "System",
|
borderRadius: 16,
|
||||||
}),
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import { toastConfig } from "@/config";
|
|||||||
import { setRouterInstance } from "@/config/auth";
|
import { setRouterInstance } from "@/config/auth";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { I18nProvider } from "@/hooks/use-i18n";
|
import { I18nProvider } from "@/hooks/use-i18n";
|
||||||
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
|
import {
|
||||||
|
ThemeProvider as AppThemeProvider,
|
||||||
|
useThemeContext,
|
||||||
|
} from "@/hooks/use-theme-context";
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import "../global.css";
|
import "../global.css";
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
interface ButtonCancelTripProps {
|
|
||||||
title?: string;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
|
||||||
title,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.button}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.text}>{displayTitle}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2, // cho Android
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonCancelTrip;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { queryGpsData } from "@/controller/DeviceController";
|
|
||||||
import {
|
|
||||||
queryStartNewHaul,
|
|
||||||
queryUpdateTripState,
|
|
||||||
} from "@/controller/TripController";
|
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import {
|
|
||||||
showErrorToast,
|
|
||||||
showSuccessToast,
|
|
||||||
showWarningToast,
|
|
||||||
} from "@/services/toast_service";
|
|
||||||
import { useTrip } from "@/state/use-trip";
|
|
||||||
import { AntDesign } from "@expo/vector-icons";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Alert, StyleSheet, View } from "react-native";
|
|
||||||
import IconButton from "./IconButton";
|
|
||||||
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
|
||||||
|
|
||||||
interface StartButtonProps {
|
|
||||||
gpsData?: Model.GPSResponse;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface a {
|
|
||||||
fishingLogs?: Model.FishingLogInfo[] | null;
|
|
||||||
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
|
||||||
isEditing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|
||||||
gpsData,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const [isStarted, setIsStarted] = useState(false);
|
|
||||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const { trip, getTrip } = useTrip();
|
|
||||||
useEffect(() => {
|
|
||||||
getTrip();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkHaulFinished = () => {
|
|
||||||
return trip?.fishing_logs?.some((h) => h.status === 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
if (isStarted) {
|
|
||||||
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
|
||||||
{
|
|
||||||
text: t("trip.cancelButton"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("trip.endButton"),
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(false);
|
|
||||||
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
|
||||||
{
|
|
||||||
text: t("trip.cancelButton"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("trip.startButton"),
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(true);
|
|
||||||
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onPress) {
|
|
||||||
onPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartTrip = async (state: number, note?: string) => {
|
|
||||||
if (trip?.trip_status !== 2) {
|
|
||||||
showWarningToast(t("trip.alreadyStarted"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await queryUpdateTripState({
|
|
||||||
status: state,
|
|
||||||
note: note || "",
|
|
||||||
});
|
|
||||||
if (resp.status === 200) {
|
|
||||||
showSuccessToast(t("trip.startTripSuccess"));
|
|
||||||
await getTrip();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error stating trip :", error);
|
|
||||||
showErrorToast("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewHaul = async () => {
|
|
||||||
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
|
||||||
showWarningToast(t("trip.finishCurrentHaul"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!gpsData) {
|
|
||||||
const response = await queryGpsData();
|
|
||||||
gpsData = response.data;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body: Model.NewFishingLogRequest = {
|
|
||||||
trip_id: trip?.id || "",
|
|
||||||
start_at: new Date(),
|
|
||||||
start_lat: gpsData.lat,
|
|
||||||
start_lon: gpsData.lon,
|
|
||||||
weather_description: t("trip.weatherDescription"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await queryStartNewHaul(body);
|
|
||||||
if (resp.status === 200) {
|
|
||||||
showSuccessToast(t("trip.startHaulSuccess"));
|
|
||||||
await getTrip();
|
|
||||||
} else {
|
|
||||||
showErrorToast(t("trip.createHaulFailed"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
// showErrorToast(t("trip.createHaulFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
|
||||||
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{trip?.trip_status === 2 ? (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" />}
|
|
||||||
type="primary"
|
|
||||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
|
||||||
onPress={async () => handleStartTrip(3)}
|
|
||||||
>
|
|
||||||
{t("trip.startTrip")}
|
|
||||||
</IconButton>
|
|
||||||
) : checkHaulFinished() ? (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" color={"white"} />}
|
|
||||||
type="primary"
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
|
||||||
>
|
|
||||||
{t("trip.endHaul")}
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" color={"white"} />}
|
|
||||||
type="primary"
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onPress={async () => {
|
|
||||||
createNewHaul();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("trip.startHaul")}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<CreateOrUpdateHaulModal
|
|
||||||
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
|
||||||
fishingLogIndex={trip?.fishing_logs?.length!}
|
|
||||||
isVisible={isFinishHaulModalOpen}
|
|
||||||
onClose={function (): void {
|
|
||||||
setIsFinishHaulModalOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#4ecdc4", // màu ngọc lam
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 3,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
elevation: 3, // hiệu ứng nổi trên Android
|
|
||||||
},
|
|
||||||
buttonActive: {
|
|
||||||
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonCreateNewHaulOrTrip;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
interface ButtonEndTripProps {
|
|
||||||
title?: string;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const displayTitle = title || t("trip.buttonEndTrip.title");
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.button}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
<Text style={styles.text}>{displayTitle}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#ed9434", // màu cam sáng
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 28,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 3,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2, // hiệu ứng nổi trên Android
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonEndTrip;
|
|
||||||
246
components/DraggablePanel.tsx
Normal file
246
components/DraggablePanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
interface DraggablePanelProps {
|
||||||
|
minHeightPct?: number;
|
||||||
|
maxHeightPct?: number;
|
||||||
|
initialState?: "min" | "max";
|
||||||
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DraggablePanel({
|
||||||
|
minHeightPct = 0.1,
|
||||||
|
maxHeightPct = 0.6,
|
||||||
|
initialState = "min",
|
||||||
|
onExpandedChange,
|
||||||
|
children,
|
||||||
|
}: DraggablePanelProps) {
|
||||||
|
const { height: screenHeight } = useWindowDimensions();
|
||||||
|
const bottomOffset = useBottomTabBarHeight();
|
||||||
|
|
||||||
|
const minHeight = screenHeight * minHeightPct;
|
||||||
|
const maxHeight = screenHeight * maxHeightPct;
|
||||||
|
const [iconName, setIconName] = useState<"chevron-down" | "chevron-up">(
|
||||||
|
initialState === "max" ? "chevron-down" : "chevron-up"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sử dụng translateY để điều khiển vị trí
|
||||||
|
const translateY = useSharedValue(
|
||||||
|
initialState === "min"
|
||||||
|
? screenHeight - minHeight - bottomOffset
|
||||||
|
: screenHeight - maxHeight - bottomOffset
|
||||||
|
);
|
||||||
|
const isExpanded = useSharedValue(initialState === "max");
|
||||||
|
|
||||||
|
// Update khi screen height thay đổi (xoay màn hình)
|
||||||
|
useEffect(() => {
|
||||||
|
const currentHeight = isExpanded.value ? maxHeight : minHeight;
|
||||||
|
translateY.value = screenHeight - currentHeight - bottomOffset;
|
||||||
|
}, [screenHeight, minHeight, maxHeight, bottomOffset]);
|
||||||
|
|
||||||
|
const notifyExpandedChange = (expanded: boolean) => {
|
||||||
|
if (onExpandedChange) {
|
||||||
|
onExpandedChange(expanded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePanel = () => {
|
||||||
|
const newExpanded = !isExpanded.value;
|
||||||
|
isExpanded.value = newExpanded;
|
||||||
|
|
||||||
|
const targetY = newExpanded
|
||||||
|
? screenHeight - maxHeight - bottomOffset
|
||||||
|
: screenHeight - minHeight - bottomOffset;
|
||||||
|
|
||||||
|
translateY.value = withTiming(targetY, {
|
||||||
|
duration: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
notifyExpandedChange(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startY = useSharedValue(0);
|
||||||
|
|
||||||
|
const panGesture = Gesture.Pan()
|
||||||
|
.onStart(() => {
|
||||||
|
startY.value = translateY.value;
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
// Cập nhật translateY theo gesture
|
||||||
|
const newY = startY.value + event.translationY;
|
||||||
|
|
||||||
|
// Clamp giá trị trong khoảng [screenHeight - maxHeight - bottomOffset, screenHeight - minHeight - bottomOffset]
|
||||||
|
const minY = screenHeight - maxHeight - bottomOffset;
|
||||||
|
const maxY = screenHeight - minHeight - bottomOffset;
|
||||||
|
|
||||||
|
if (newY >= minY && newY <= maxY) {
|
||||||
|
translateY.value = newY;
|
||||||
|
} else if (newY < minY) {
|
||||||
|
translateY.value = minY;
|
||||||
|
} else if (newY > maxY) {
|
||||||
|
translateY.value = maxY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
// Tính toán vị trí để snap
|
||||||
|
const currentHeight = screenHeight - translateY.value - bottomOffset;
|
||||||
|
const midHeight = (minHeight + maxHeight) / 2;
|
||||||
|
|
||||||
|
// Kiểm tra velocity để quyết định snap
|
||||||
|
const snapToMax = event.velocityY < -100 || currentHeight > midHeight;
|
||||||
|
|
||||||
|
const targetY = snapToMax
|
||||||
|
? screenHeight - maxHeight - bottomOffset + 40
|
||||||
|
: screenHeight - minHeight - bottomOffset;
|
||||||
|
|
||||||
|
isExpanded.value = snapToMax;
|
||||||
|
runOnJS(notifyExpandedChange)(snapToMax);
|
||||||
|
|
||||||
|
translateY.value = withSpring(targetY, {
|
||||||
|
damping: 20,
|
||||||
|
stiffness: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sử dụng useAnimatedReaction để cập nhật icon dựa trên chiều cao
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => {
|
||||||
|
const currentHeight = screenHeight - translateY.value - bottomOffset;
|
||||||
|
return currentHeight > minHeight;
|
||||||
|
},
|
||||||
|
(isCurrentlyExpanded) => {
|
||||||
|
const newIcon = isCurrentlyExpanded ? "chevron-down" : "chevron-up";
|
||||||
|
runOnJS(setIconName)(newIcon);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.panelContainer, animatedStyle]}>
|
||||||
|
<View style={[styles.panel, { height: maxHeight }]}>
|
||||||
|
{/* Header với drag handle và nút toggle */}
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Pressable onPress={togglePanel} style={styles.header}>
|
||||||
|
<View style={styles.dragHandle} />
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={togglePanel}
|
||||||
|
style={styles.toggleButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name={iconName} size={24} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Pressable>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
{/* Nội dung */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{children || (
|
||||||
|
<View style={styles.placeholderContent}>
|
||||||
|
<Text style={styles.placeholderText}>Draggable Panel</Text>
|
||||||
|
<Text style={styles.placeholderSubtext}>
|
||||||
|
Click hoặc kéo để mở rộng panel này
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.placeholderSubtext}>
|
||||||
|
Min: {(minHeightPct * 100).toFixed(0)}% | Max:{" "}
|
||||||
|
{(maxHeightPct * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
panelContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
|
pointerEvents: "box-none",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: -2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
dragHandle: {
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#D1D5DB",
|
||||||
|
borderRadius: 2,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
toggleButton: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 16,
|
||||||
|
top: 1,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
// paddingBottom: 16,
|
||||||
|
},
|
||||||
|
placeholderContent: {
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
placeholderSubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#666",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,16 +20,16 @@ export interface SelectOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectProps {
|
export interface SelectProps {
|
||||||
value?: string | number;
|
value?: string | number | (string | number)[];
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number | (string | number)[];
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
onChange?: (value: string | number | undefined) => void;
|
onChange?: (value: string | number | (string | number)[] | undefined) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
mode?: "single" | "multiple"; // multiple not implemented yet
|
mode?: "single" | "multiple";
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
size?: "small" | "middle" | "large";
|
size?: "small" | "middle" | "large";
|
||||||
listStyle?: StyleProp<ViewStyle>;
|
listStyle?: StyleProp<ViewStyle>;
|
||||||
@@ -38,7 +38,7 @@ export interface SelectProps {
|
|||||||
/**
|
/**
|
||||||
* Select
|
* Select
|
||||||
* A Select component inspired by Ant Design, adapted for React Native.
|
* A Select component inspired by Ant Design, adapted for React Native.
|
||||||
* Supports single selection, search, clear, loading, disabled states.
|
* Supports single and multiple selection, search, clear, loading, disabled states.
|
||||||
*/
|
*/
|
||||||
const Select: React.FC<SelectProps> = ({
|
const Select: React.FC<SelectProps> = ({
|
||||||
value,
|
value,
|
||||||
@@ -55,16 +55,23 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
listStyle,
|
listStyle,
|
||||||
size = "middle",
|
size = "middle",
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedValue, setSelectedValue] = useState<
|
const initialValue = value ?? defaultValue;
|
||||||
string | number | undefined
|
const [selectedValues, setSelectedValues] = useState<(string | number)[]>(
|
||||||
>(value ?? defaultValue);
|
Array.isArray(initialValue)
|
||||||
|
? initialValue
|
||||||
|
: initialValue
|
||||||
|
? [initialValue]
|
||||||
|
: []
|
||||||
|
);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [containerHeight, setContainerHeight] = useState(0);
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
const [textHeight, setTextHeight] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedValue(value);
|
const newVal = value ?? defaultValue;
|
||||||
}, [value]);
|
setSelectedValues(Array.isArray(newVal) ? newVal : newVal ? [newVal] : []);
|
||||||
|
}, [value, defaultValue]);
|
||||||
|
|
||||||
const filteredOptions = showSearch
|
const filteredOptions = showSearch
|
||||||
? options.filter((opt) =>
|
? options.filter((opt) =>
|
||||||
@@ -72,17 +79,31 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
)
|
)
|
||||||
: options;
|
: options;
|
||||||
|
|
||||||
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
|
||||||
|
|
||||||
const handleSelect = (val: string | number) => {
|
const handleSelect = (val: string | number) => {
|
||||||
setSelectedValue(val);
|
let newSelected: (string | number)[];
|
||||||
onChange?.(val);
|
if (mode === "single") {
|
||||||
setIsOpen(false);
|
newSelected = [val];
|
||||||
setSearchText("");
|
} else {
|
||||||
|
newSelected = selectedValues.includes(val)
|
||||||
|
? selectedValues.filter((v) => v !== val)
|
||||||
|
: [...selectedValues, val];
|
||||||
|
}
|
||||||
|
setSelectedValues(newSelected);
|
||||||
|
onChange?.(
|
||||||
|
mode === "single"
|
||||||
|
? newSelected.length > 0
|
||||||
|
? newSelected[0]
|
||||||
|
: undefined
|
||||||
|
: newSelected
|
||||||
|
);
|
||||||
|
if (mode === "single") {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setSelectedValue(undefined);
|
setSelectedValues([]);
|
||||||
onChange?.(undefined);
|
onChange?.(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,13 +121,26 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
? colors.backgroundSecondary
|
? colors.backgroundSecondary
|
||||||
: colors.surface;
|
: colors.surface;
|
||||||
|
|
||||||
|
let displayText = placeholder;
|
||||||
|
if (selectedValues.length > 0) {
|
||||||
|
if (mode === "single") {
|
||||||
|
const opt = options.find((o) => o.value === selectedValues[0]);
|
||||||
|
displayText = opt?.label || placeholder;
|
||||||
|
} else {
|
||||||
|
const labels = selectedValues
|
||||||
|
.map((v) => options.find((o) => o.value === v)?.label)
|
||||||
|
.filter(Boolean);
|
||||||
|
displayText = labels.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.wrapper}>
|
<View style={styles.wrapper}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
{
|
{
|
||||||
height: sz.height,
|
height: Math.max(sz.height, textHeight + 16), // Add padding
|
||||||
paddingHorizontal: sz.paddingHorizontal,
|
paddingHorizontal: sz.paddingHorizontal,
|
||||||
backgroundColor: selectBackgroundColor,
|
backgroundColor: selectBackgroundColor,
|
||||||
borderColor: disabled ? colors.border : colors.primary,
|
borderColor: disabled ? colors.border : colors.primary,
|
||||||
@@ -129,19 +163,19 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
fontSize: sz.fontSize,
|
fontSize: sz.fontSize,
|
||||||
color: disabled
|
color: disabled
|
||||||
? colors.textSecondary
|
? colors.textSecondary
|
||||||
: selectedValue
|
: selectedValues.length > 0
|
||||||
? colors.text
|
? colors.text
|
||||||
: colors.textSecondary,
|
: colors.textSecondary,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}
|
onLayout={(e) => setTextHeight(e.nativeEvent.layout.height)}
|
||||||
>
|
>
|
||||||
{selectedOption?.label || placeholder}
|
{displayText}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.suffix}>
|
<View style={styles.suffix}>
|
||||||
{allowClear && selectedValue && !loading ? (
|
{allowClear && selectedValues.length > 0 && !loading ? (
|
||||||
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
<TouchableOpacity onPress={handleClear} style={styles.icon}>
|
||||||
<AntDesign name="close" size={16} color={colors.textSecondary} />
|
<AntDesign name="close" size={16} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -193,7 +227,7 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
borderBottomColor: colors.separator,
|
borderBottomColor: colors.separator,
|
||||||
},
|
},
|
||||||
item.disabled && styles.optionDisabled,
|
item.disabled && styles.optionDisabled,
|
||||||
selectedValue === item.value && {
|
selectedValues.includes(item.value) && {
|
||||||
backgroundColor: colors.primary + "20", // Add transparency to primary color
|
backgroundColor: colors.primary + "20", // Add transparency to primary color
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -209,7 +243,7 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
item.disabled && {
|
item.disabled && {
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
},
|
},
|
||||||
selectedValue === item.value && {
|
selectedValues.includes(item.value) && {
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
@@ -217,7 +251,7 @@ const Select: React.FC<SelectProps> = ({
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedValue === item.value && (
|
{selectedValues.includes(item.value) && (
|
||||||
<AntDesign name="check" size={16} color={colors.primary} />
|
<AntDesign name="check" size={16} color={colors.primary} />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
534
components/ShipSearchForm.tsx
Normal file
534
components/ShipSearchForm.tsx
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { Colors } from "@/config";
|
||||||
|
import { queryShipGroups } from "@/controller/DeviceController";
|
||||||
|
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
|
||||||
|
import { useShipTypes } from "@/state/use-ship-types";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import Select from "./Select";
|
||||||
|
import Slider from "./Slider";
|
||||||
|
|
||||||
|
interface ShipSearchFormProps {
|
||||||
|
initialValues?: Partial<SearchShipResponse>;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit?: (data: SearchShipResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchShipResponse {
|
||||||
|
ship_name: string;
|
||||||
|
ship_length: [number, number];
|
||||||
|
reg_number: string;
|
||||||
|
ship_power: [number, number];
|
||||||
|
ship_type: string | number;
|
||||||
|
alarm_list: string;
|
||||||
|
ship_group_id: string;
|
||||||
|
group_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShipSearchForm = (props: ShipSearchFormProps) => {
|
||||||
|
const { colors, colorScheme } = useTheme();
|
||||||
|
const styles = useMemo(
|
||||||
|
() => createStyles(colors, colorScheme),
|
||||||
|
[colors, colorScheme]
|
||||||
|
);
|
||||||
|
const { shipTypes, getShipTypes } = useShipTypes();
|
||||||
|
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
|
||||||
|
const [slideAnim] = useState(new Animated.Value(0));
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
|
||||||
|
defaultValues: {
|
||||||
|
ship_name: props.initialValues?.ship_name || "",
|
||||||
|
reg_number: props.initialValues?.reg_number || "",
|
||||||
|
ship_length: [0, 100],
|
||||||
|
ship_power: [0, 100000],
|
||||||
|
ship_type: props.initialValues?.ship_type || "",
|
||||||
|
alarm_list: props.initialValues?.alarm_list || "",
|
||||||
|
ship_group_id: props.initialValues?.ship_group_id || "",
|
||||||
|
group_id: props.initialValues?.group_id || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shipLengthValue = watch("ship_length");
|
||||||
|
const shipPowerValue = watch("ship_power");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shipTypes.length === 0) {
|
||||||
|
getShipTypes();
|
||||||
|
}
|
||||||
|
}, [shipTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getShipGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
Animated.spring(slideAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
tension: 50,
|
||||||
|
friction: 8,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
slideAnim.setValue(0);
|
||||||
|
}
|
||||||
|
}, [props.isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.initialValues) {
|
||||||
|
reset({
|
||||||
|
ship_name: props.initialValues.ship_name || "",
|
||||||
|
reg_number: props.initialValues.reg_number || "",
|
||||||
|
ship_length: [
|
||||||
|
props.initialValues.ship_length?.[0] || 0,
|
||||||
|
props.initialValues.ship_length?.[1] || 100,
|
||||||
|
],
|
||||||
|
ship_power: [
|
||||||
|
props.initialValues.ship_power?.[0] || 0,
|
||||||
|
props.initialValues.ship_power?.[1] || 100000,
|
||||||
|
],
|
||||||
|
ship_type: props.initialValues.ship_type || "",
|
||||||
|
alarm_list: props.initialValues.alarm_list || "",
|
||||||
|
ship_group_id: props.initialValues.ship_group_id || "",
|
||||||
|
group_id: props.initialValues.group_id || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.initialValues]);
|
||||||
|
|
||||||
|
const getShipGroups = async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryShipGroups();
|
||||||
|
if (response && response.data) {
|
||||||
|
setGroupShips(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching ship groups:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const alarmListLabel = [
|
||||||
|
{
|
||||||
|
label: "Tiếp cận vùng hạn chế",
|
||||||
|
value: "50:10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Đã ra (vào) vùng hạn chế)",
|
||||||
|
value: "50:11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Đang đánh bắt trong vùng hạn chế",
|
||||||
|
value: "50:12",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const onSubmitForm = (data: SearchShipResponse) => {
|
||||||
|
props.onSubmit?.(data);
|
||||||
|
console.log("Data: ", data);
|
||||||
|
|
||||||
|
// props.onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
reset({
|
||||||
|
ship_name: "",
|
||||||
|
reg_number: "",
|
||||||
|
ship_length: [0, 100],
|
||||||
|
ship_power: [0, 100000],
|
||||||
|
ship_type: "",
|
||||||
|
alarm_list: "",
|
||||||
|
ship_group_id: "",
|
||||||
|
group_id: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateY = slideAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [600, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
visible={props.isOpen}
|
||||||
|
onRequestClose={props.onClose}
|
||||||
|
>
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={props.onClose}>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.modalContent, { transform: [{ translateY }] }]}
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.dragIndicator} />
|
||||||
|
<Text style={[styles.headerTitle, { color: colors.text }]}>
|
||||||
|
Tìm kiếm tàu
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={props.onClose}
|
||||||
|
style={styles.closeButton}
|
||||||
|
>
|
||||||
|
<Text style={[styles.closeButtonText, { color: colors.text }]}>
|
||||||
|
✕
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form Content */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
{/* Tên tàu */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Tên tàu
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ship_name"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<TextInput
|
||||||
|
placeholder="Hoàng Sa 001"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Số đăng ký */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Số đăng ký
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="reg_number"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<TextInput
|
||||||
|
placeholder="VN-00001"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chiều dài */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Chiều dài ({shipLengthValue[0]}m - {shipLengthValue[1]}m)
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ship_length"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
range={true}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
marks={{
|
||||||
|
0: "0m",
|
||||||
|
50: "50m",
|
||||||
|
100: "100m",
|
||||||
|
}}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange(val as [number, number])
|
||||||
|
}
|
||||||
|
activeColor={colors.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Công suất */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Công suất ({shipPowerValue[0]}kW - {shipPowerValue[1]}kW)
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ship_power"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
range={true}
|
||||||
|
min={0}
|
||||||
|
max={100000}
|
||||||
|
step={1000}
|
||||||
|
value={value}
|
||||||
|
marks={{
|
||||||
|
0: "0kW",
|
||||||
|
50000: "50,000kW",
|
||||||
|
100000: "100,000kW",
|
||||||
|
}}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onChange(val as [number, number])
|
||||||
|
}
|
||||||
|
activeColor={colors.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loại tàu */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Loại tàu
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ship_type"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
options={shipTypes.map((type) => ({
|
||||||
|
label: type.name || "",
|
||||||
|
value: type.id || 0,
|
||||||
|
}))}
|
||||||
|
placeholder="Chọn loại tàu"
|
||||||
|
mode="multiple"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cảnh báo */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Cảnh báo
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="alarm_list"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
options={alarmListLabel.map((type) => ({
|
||||||
|
label: type.label || "",
|
||||||
|
value: type.value || "",
|
||||||
|
}))}
|
||||||
|
placeholder="Chọn loại cảnh báo"
|
||||||
|
mode="multiple"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Đội tàu */}
|
||||||
|
<View style={styles.fieldGroup}>
|
||||||
|
<Text style={[styles.label, { color: colors.text }]}>
|
||||||
|
Đội tàu
|
||||||
|
</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="ship_group_id"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
options={groupShips.map((group) => ({
|
||||||
|
label: group.name || "",
|
||||||
|
value: group.id || "",
|
||||||
|
}))}
|
||||||
|
placeholder="Chọn đội tàu"
|
||||||
|
mode="multiple"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="h-12"></View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View
|
||||||
|
style={[styles.actionButtons, { borderTopColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.resetButton, { borderColor: colors.border }]}
|
||||||
|
onPress={onReset}
|
||||||
|
>
|
||||||
|
<Text style={[styles.resetButtonText, { color: colors.text }]}>
|
||||||
|
Đặt lại
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.submitButton,
|
||||||
|
{ backgroundColor: colors.primary },
|
||||||
|
]}
|
||||||
|
onPress={handleSubmit(onSubmitForm)}
|
||||||
|
>
|
||||||
|
<Text style={styles.submitButtonText}>Tìm kiếm</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
height: "85%",
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: -4,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
dragIndicator: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: colors.border,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "300",
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
formSection: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
fieldGroup: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
sliderContainer: {
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ShipSearchForm;
|
||||||
347
components/Slider.tsx
Normal file
347
components/Slider.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LayoutChangeEvent, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
interface SliderProps {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
range?: boolean;
|
||||||
|
value?: number | [number, number];
|
||||||
|
onValueChange?: (value: any) => void;
|
||||||
|
onSlidingComplete?: (value: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
trackHeight?: number;
|
||||||
|
thumbSize?: number;
|
||||||
|
activeColor?: string;
|
||||||
|
inactiveColor?: string;
|
||||||
|
marks?: Record<number, string>;
|
||||||
|
style?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Slider({
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
range = false,
|
||||||
|
value = 0,
|
||||||
|
onValueChange,
|
||||||
|
onSlidingComplete,
|
||||||
|
disabled = false,
|
||||||
|
trackHeight = 4,
|
||||||
|
thumbSize = 20,
|
||||||
|
activeColor = "#1677ff", // Ant Design blue
|
||||||
|
inactiveColor = "#e5e7eb", // Gray-200
|
||||||
|
marks,
|
||||||
|
style,
|
||||||
|
}: SliderProps) {
|
||||||
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
// Shared values for positions
|
||||||
|
const translateX1 = useSharedValue(0); // Left thumb (or 0 for single)
|
||||||
|
const translateX2 = useSharedValue(0); // Right thumb (or value for single)
|
||||||
|
|
||||||
|
const isDragging1 = useSharedValue(false);
|
||||||
|
const isDragging2 = useSharedValue(false);
|
||||||
|
const scale1 = useSharedValue(1);
|
||||||
|
const scale2 = useSharedValue(1);
|
||||||
|
const context1 = useSharedValue(0);
|
||||||
|
const context2 = useSharedValue(0);
|
||||||
|
|
||||||
|
// Calculate position from value
|
||||||
|
const getPositionFromValue = (val: number) => {
|
||||||
|
"worklet";
|
||||||
|
if (width === 0) return 0;
|
||||||
|
const clampedVal = Math.min(Math.max(val, min), max);
|
||||||
|
return ((clampedVal - min) / (max - min)) * width;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate value from position
|
||||||
|
const getValueFromPosition = (pos: number) => {
|
||||||
|
"worklet";
|
||||||
|
if (width === 0) return min;
|
||||||
|
const percentage = Math.min(Math.max(pos, 0), width) / width;
|
||||||
|
const rawValue = min + percentage * (max - min);
|
||||||
|
|
||||||
|
// Snap to step
|
||||||
|
const steppedValue = Math.round(rawValue / step) * step;
|
||||||
|
return Math.min(Math.max(steppedValue, min), max);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update positions when props change
|
||||||
|
useEffect(() => {
|
||||||
|
if (width === 0) return;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const vals = Array.isArray(value) ? value : [min, value as number];
|
||||||
|
const v1 = Math.min(vals[0], vals[1]);
|
||||||
|
const v2 = Math.max(vals[0], vals[1]);
|
||||||
|
|
||||||
|
if (!isDragging1.value) {
|
||||||
|
translateX1.value = withTiming(getPositionFromValue(v1), {
|
||||||
|
duration: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isDragging2.value) {
|
||||||
|
translateX2.value = withTiming(getPositionFromValue(v2), {
|
||||||
|
duration: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const val = typeof value === "number" ? value : value[0];
|
||||||
|
if (!isDragging2.value) {
|
||||||
|
translateX2.value = withTiming(getPositionFromValue(val), {
|
||||||
|
duration: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
translateX1.value = 0; // Always 0 for single slider track start
|
||||||
|
}
|
||||||
|
}, [value, width, min, max, range]);
|
||||||
|
|
||||||
|
// Thumb 1 Gesture (Only for range)
|
||||||
|
const thumb1Gesture = Gesture.Pan()
|
||||||
|
.enabled(!disabled && range)
|
||||||
|
.onStart(() => {
|
||||||
|
context1.value = translateX1.value;
|
||||||
|
isDragging1.value = true;
|
||||||
|
scale1.value = withSpring(1.2);
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
if (width === 0) return;
|
||||||
|
let newPos = context1.value + event.translationX;
|
||||||
|
// Constrain: 0 <= newPos <= translateX2
|
||||||
|
const maxPos = translateX2.value;
|
||||||
|
newPos = Math.min(Math.max(newPos, 0), maxPos);
|
||||||
|
translateX1.value = newPos;
|
||||||
|
|
||||||
|
if (onValueChange) {
|
||||||
|
const v1 = getValueFromPosition(newPos);
|
||||||
|
const v2 = getValueFromPosition(translateX2.value);
|
||||||
|
runOnJS(onValueChange)([v1, v2]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
isDragging1.value = false;
|
||||||
|
scale1.value = withSpring(1);
|
||||||
|
if (onSlidingComplete) {
|
||||||
|
const v1 = getValueFromPosition(translateX1.value);
|
||||||
|
const v2 = getValueFromPosition(translateX2.value);
|
||||||
|
runOnJS(onSlidingComplete)([v1, v2]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thumb 2 Gesture (Main thumb for single, Right thumb for range)
|
||||||
|
const thumb2Gesture = Gesture.Pan()
|
||||||
|
.enabled(!disabled)
|
||||||
|
.onStart(() => {
|
||||||
|
context2.value = translateX2.value;
|
||||||
|
isDragging2.value = true;
|
||||||
|
scale2.value = withSpring(1.2);
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
if (width === 0) return;
|
||||||
|
let newPos = context2.value + event.translationX;
|
||||||
|
// Constrain: translateX1 <= newPos <= width
|
||||||
|
const minPos = range ? translateX1.value : 0;
|
||||||
|
newPos = Math.min(Math.max(newPos, minPos), width);
|
||||||
|
translateX2.value = newPos;
|
||||||
|
|
||||||
|
if (onValueChange) {
|
||||||
|
const v2 = getValueFromPosition(newPos);
|
||||||
|
if (range) {
|
||||||
|
const v1 = getValueFromPosition(translateX1.value);
|
||||||
|
runOnJS(onValueChange)([v1, v2]);
|
||||||
|
} else {
|
||||||
|
runOnJS(onValueChange)(v2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
isDragging2.value = false;
|
||||||
|
scale2.value = withSpring(1);
|
||||||
|
if (onSlidingComplete) {
|
||||||
|
const v2 = getValueFromPosition(translateX2.value);
|
||||||
|
if (range) {
|
||||||
|
const v1 = getValueFromPosition(translateX1.value);
|
||||||
|
runOnJS(onSlidingComplete)([v1, v2]);
|
||||||
|
} else {
|
||||||
|
runOnJS(onSlidingComplete)(v2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
left: range ? translateX1.value : 0,
|
||||||
|
width: range ? translateX2.value - translateX1.value : translateX2.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const thumb1Style = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{ translateX: translateX1.value - thumbSize / 2 },
|
||||||
|
{ scale: scale1.value },
|
||||||
|
],
|
||||||
|
zIndex: isDragging1.value ? 10 : 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const thumb2Style = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{ translateX: translateX2.value - thumbSize / 2 },
|
||||||
|
{ scale: scale2.value },
|
||||||
|
],
|
||||||
|
zIndex: isDragging2.value ? 10 : 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLayout = (event: LayoutChangeEvent) => {
|
||||||
|
setWidth(event.nativeEvent.layout.width);
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerHeight = Math.max(trackHeight, thumbSize) + (marks ? 30 : 0);
|
||||||
|
const marksTop = Math.max(trackHeight, thumbSize) + 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style, { height: containerHeight }]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.track,
|
||||||
|
{
|
||||||
|
height: trackHeight,
|
||||||
|
backgroundColor: inactiveColor,
|
||||||
|
borderRadius: trackHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onLayout={onLayout}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.activeTrack,
|
||||||
|
trackStyle,
|
||||||
|
{
|
||||||
|
height: trackHeight,
|
||||||
|
backgroundColor: activeColor,
|
||||||
|
borderRadius: trackHeight / 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Thumb 1 - Only render if range is true */}
|
||||||
|
{range && (
|
||||||
|
<GestureDetector gesture={thumb1Gesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.thumb,
|
||||||
|
thumb1Style,
|
||||||
|
{
|
||||||
|
width: thumbSize,
|
||||||
|
height: thumbSize,
|
||||||
|
borderRadius: thumbSize / 2,
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderColor: activeColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
disabled && styles.disabledThumb,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumb 2 - Always render */}
|
||||||
|
<GestureDetector gesture={thumb2Gesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.thumb,
|
||||||
|
thumb2Style,
|
||||||
|
{
|
||||||
|
width: thumbSize,
|
||||||
|
height: thumbSize,
|
||||||
|
borderRadius: thumbSize / 2,
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderColor: activeColor,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
disabled && styles.disabledThumb,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
{/* Marks */}
|
||||||
|
{marks && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: marksTop,
|
||||||
|
width: "100%",
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(marks).map(([key, label]) => {
|
||||||
|
const val = Number(key);
|
||||||
|
const pos = getPositionFromValue(val);
|
||||||
|
const leftPos = Math.max(0, Math.min(pos - 10, width - 40));
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: leftPos,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666",
|
||||||
|
textAlign: "center",
|
||||||
|
width: "auto",
|
||||||
|
marginTop: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
width: "100%",
|
||||||
|
position: "absolute",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
activeTrack: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
},
|
||||||
|
thumb: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
disabledThumb: {
|
||||||
|
borderColor: "#ccc",
|
||||||
|
},
|
||||||
|
});
|
||||||
439
components/alarm/AlarmCard.tsx
Normal file
439
components/alarm/AlarmCard.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import {
|
||||||
|
queryConfirmAlarm,
|
||||||
|
queryrUnconfirmAlarm,
|
||||||
|
} from "@/controller/AlarmController";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface AlarmCardProps {
|
||||||
|
alarm: Model.Alarm;
|
||||||
|
onReload?: (onReload: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => note.trim().length > 0 || alarm.confirmed,
|
||||||
|
[note, alarm.confirmed]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine level and colors based on alarm level
|
||||||
|
const getAlarmConfig = (level?: number) => {
|
||||||
|
if (level === 3) {
|
||||||
|
// Danger - Red
|
||||||
|
return {
|
||||||
|
level: 3,
|
||||||
|
icon: "warning" as const,
|
||||||
|
bgColor: "#fee2e2",
|
||||||
|
borderColor: "#DC0E0E",
|
||||||
|
iconColor: "#dc2626",
|
||||||
|
statusBg: "#dcfce7",
|
||||||
|
statusText: "#166534",
|
||||||
|
};
|
||||||
|
} else if (level === 2) {
|
||||||
|
// Caution - Yellow/Orange
|
||||||
|
return {
|
||||||
|
level: 2,
|
||||||
|
icon: "alert-circle" as const,
|
||||||
|
bgColor: "#fef3c7",
|
||||||
|
borderColor: "#FF6C0C",
|
||||||
|
iconColor: "#d97706",
|
||||||
|
statusBg: "#fef08a",
|
||||||
|
statusText: "#713f12",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Info - Green
|
||||||
|
return {
|
||||||
|
level: 1,
|
||||||
|
icon: "information-circle" as const,
|
||||||
|
bgColor: "#fffefe",
|
||||||
|
borderColor: "#FF937E",
|
||||||
|
iconColor: "#FF937E",
|
||||||
|
statusBg: "#dcfce7",
|
||||||
|
statusText: "#166534",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getAlarmConfig(alarm.level);
|
||||||
|
|
||||||
|
const formatDate = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePayload = () => {
|
||||||
|
if (!alarm.id || !alarm.thing_id || !alarm.time) {
|
||||||
|
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfirm = async (action: "confirm" | "unconfirm") => {
|
||||||
|
if (!ensurePayload()) return;
|
||||||
|
if (action === "confirm" && note.trim().length === 0) {
|
||||||
|
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
if (action === "confirm") {
|
||||||
|
await queryConfirmAlarm({
|
||||||
|
id: alarm.id!,
|
||||||
|
thing_id: alarm.thing_id!,
|
||||||
|
time: alarm.time!,
|
||||||
|
description: note.trim(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await queryrUnconfirmAlarm({
|
||||||
|
id: alarm.id!,
|
||||||
|
thing_id: alarm.thing_id!,
|
||||||
|
time: alarm.time!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onReload?.(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Cannot confirm/unconfirm alarm: ", error);
|
||||||
|
const status = error?.response?.status ?? error?.status;
|
||||||
|
// If server returns 404, ignore silently
|
||||||
|
if (status !== 404) {
|
||||||
|
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setShowModal(false);
|
||||||
|
setNote("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = (alarm: Model.Alarm) => {
|
||||||
|
if (alarm.confirmed) {
|
||||||
|
Alert.alert(
|
||||||
|
"Thông báo",
|
||||||
|
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
|
||||||
|
[
|
||||||
|
{ text: "Hủy", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Ngừng xác nhận",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => submitConfirm("unconfirm"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: config.bgColor,
|
||||||
|
borderLeftColor: config.borderColor,
|
||||||
|
borderLeftWidth: 5,
|
||||||
|
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Left Side - Icon and Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View
|
||||||
|
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
|
||||||
|
>
|
||||||
|
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title and Info */}
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
{/* Name */}
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text
|
||||||
|
style={[styles.title, { color: colors.text }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{alarm.name || alarm.thing_name || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Location (thing_name) and Time */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||||
|
>
|
||||||
|
Trạm
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoValue, { color: colors.text }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{alarm.thing_name || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||||
|
>
|
||||||
|
Thời gian
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoValue, { color: colors.text }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{formatDate(alarm.time)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.statusContainer}
|
||||||
|
onPress={() => handlePress(alarm)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{
|
||||||
|
backgroundColor: alarm.confirmed ? "#8FD14F" : "#EEEEEE",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: alarm.confirmed ? "#166534" : "black" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{alarm.confirmed && (
|
||||||
|
<View style={styles.rightIcon}>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done"
|
||||||
|
size={20}
|
||||||
|
color={alarm.confirmed ? "#78C841" : config.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalContent,
|
||||||
|
{ backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||||
|
Nhập ghi chú xác nhận
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.text }]}
|
||||||
|
placeholder="Nhập ghi chú"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
multiline
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
editable={!submitting}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalButton, styles.cancelButton]}
|
||||||
|
onPress={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelText}>Hủy</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modalButton,
|
||||||
|
styles.confirmButton,
|
||||||
|
!canSubmit && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={() => submitConfirm("confirm")}
|
||||||
|
disabled={submitting || !canSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmText}>Xác nhận</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
// borderWidth: 1,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
// marginRight: 5,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 0.2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
rightIcon: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e5e7eb",
|
||||||
|
padding: 12,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#e5e7eb",
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
color: "#111827",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AlarmCard;
|
||||||
305
components/alarm/AlarmSearchForm.tsx
Normal file
305
components/alarm/AlarmSearchForm.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import Select, { SelectOption } from "@/components/Select";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
interface AlarmSearchFormProps {
|
||||||
|
initialValue?: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
};
|
||||||
|
onSubmit: (payload: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
confirmed: string; // Using string for Select component compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
|
||||||
|
initialValue,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
|
const levelOptions: SelectOption[] = [
|
||||||
|
{ label: "Tất cả", value: 0 },
|
||||||
|
{ label: "Cảnh báo", value: 1 },
|
||||||
|
{ label: "Nguy hiểm", value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmedOptions: SelectOption[] = [
|
||||||
|
{ label: "Tất cả", value: "" },
|
||||||
|
{ label: "Đã xác nhận", value: "true" },
|
||||||
|
{ label: "Chưa xác nhận", value: "false" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset } = useForm<FormData>({
|
||||||
|
defaultValues: {
|
||||||
|
name: initialValue?.name || "",
|
||||||
|
level: initialValue?.level || 0,
|
||||||
|
confirmed:
|
||||||
|
initialValue?.confirmed !== undefined
|
||||||
|
? initialValue.confirmed.toString()
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValue) {
|
||||||
|
reset({
|
||||||
|
name: initialValue.name || "",
|
||||||
|
level: initialValue.level || 0,
|
||||||
|
confirmed:
|
||||||
|
initialValue.confirmed !== undefined
|
||||||
|
? initialValue.confirmed.toString()
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialValue, reset]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: FormData) => {
|
||||||
|
const payload: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
} = {
|
||||||
|
...(data.name && { name: data.name }),
|
||||||
|
...(data.level !== 0 && { level: data.level }),
|
||||||
|
...(data.confirmed !== "" && {
|
||||||
|
confirmed: data.confirmed === "true",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
level: 0,
|
||||||
|
confirmed: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit empty payload to reset filters
|
||||||
|
onSubmit({});
|
||||||
|
onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
height: "auto",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Search Input */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field: { onChange, onBlur, value } }) => (
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[styles.inputWrapper, { borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.text }]}
|
||||||
|
placeholder="Tìm theo tên cảnh báo"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onChange("")}
|
||||||
|
style={styles.clearButton}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close-circle"
|
||||||
|
size={20}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Level and Confirmed Selects */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.halfWidth}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="level"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.selectContainer}>
|
||||||
|
<ThemedText style={styles.label}>Mức độ</ThemedText>
|
||||||
|
<Select
|
||||||
|
placeholder="Chọn mức độ"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={levelOptions}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.halfWidth}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="confirmed"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.selectContainer}>
|
||||||
|
<ThemedText style={styles.label}>Trạng thái</ThemedText>
|
||||||
|
<Select
|
||||||
|
placeholder="Chọn trạng thái"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={confirmedOptions}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.secondaryButton,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={handleReset}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
|
||||||
|
Đặt lại
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.primaryButton,
|
||||||
|
{ backgroundColor: colors.primary },
|
||||||
|
]}
|
||||||
|
onPress={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
|
||||||
|
Tìm kiếm
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 16,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
halfWidth: {
|
||||||
|
width: "48%",
|
||||||
|
zIndex: 5000,
|
||||||
|
},
|
||||||
|
selectContainer: {
|
||||||
|
// flex: 1, // Remove this to prevent taking full width
|
||||||
|
zIndex: 5000,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
// backgroundColor is set dynamically
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AlarmSearchForm;
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
@@ -23,6 +25,8 @@ export default function DateRangePicker({
|
|||||||
onStartDateChange,
|
onStartDateChange,
|
||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
}: DateRangePickerProps) {
|
}: DateRangePickerProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors, colorScheme } = useThemeContext();
|
||||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||||
const [showEndPicker, setShowEndPicker] = 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<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}>
|
<View style={styles.dateRangeContainer}>
|
||||||
{/* Start Date */}
|
{/* Start Date */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.dateInput}
|
style={[styles.dateInput, themedStyles.dateInput]}
|
||||||
onPress={() => setShowStartPicker(true)}
|
onPress={() => setShowStartPicker(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.dateText, !startDate && styles.placeholder]}>
|
<Text style={[styles.dateText, themedStyles.dateText, !startDate && themedStyles.placeholder]}>
|
||||||
{startDate ? formatDate(startDate) : "Ngày bắt đầu"}
|
{startDate ? formatDate(startDate) : t("diary.dateRangePicker.startDate")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="arrow-forward"
|
name="arrow-forward"
|
||||||
size={20}
|
size={20}
|
||||||
color="#9CA3AF"
|
color={colors.textSecondary}
|
||||||
style={styles.arrow}
|
style={styles.arrow}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* End Date */}
|
{/* End Date */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.dateInput}
|
style={[styles.dateInput, themedStyles.dateInput]}
|
||||||
onPress={() => setShowEndPicker(true)}
|
onPress={() => setShowEndPicker(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.dateText, !endDate && styles.placeholder]}>
|
<Text style={[styles.dateText, themedStyles.dateText, !endDate && themedStyles.placeholder]}>
|
||||||
{endDate ? formatDate(endDate) : "Ngày kết thúc"}
|
{endDate ? formatDate(endDate) : t("diary.dateRangePicker.endDate")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -85,7 +118,7 @@ export default function DateRangePicker({
|
|||||||
style={styles.calendarButton}
|
style={styles.calendarButton}
|
||||||
onPress={() => setShowStartPicker(true)}
|
onPress={() => setShowStartPicker(true)}
|
||||||
>
|
>
|
||||||
<Ionicons name="calendar-outline" size={20} color="#6B7280" />
|
<Ionicons name="calendar-outline" size={20} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -93,14 +126,14 @@ export default function DateRangePicker({
|
|||||||
{showStartPicker && (
|
{showStartPicker && (
|
||||||
<Modal transparent animationType="fade" visible={showStartPicker}>
|
<Modal transparent animationType="fade" visible={showStartPicker}>
|
||||||
<View style={styles.modalOverlay}>
|
<View style={styles.modalOverlay}>
|
||||||
<View style={styles.pickerContainer}>
|
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||||
<View style={styles.pickerHeader}>
|
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||||
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||||
<Text style={styles.cancelButton}>Hủy</Text>
|
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>{t("common.cancel")}</Text>
|
||||||
</TouchableOpacity>
|
</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)}>
|
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
|
||||||
<Text style={styles.doneButton}>Xong</Text>
|
<Text style={styles.doneButton}>{t("diary.dateRangePicker.done")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -109,6 +142,8 @@ export default function DateRangePicker({
|
|||||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||||
onChange={handleStartDateChange}
|
onChange={handleStartDateChange}
|
||||||
maximumDate={endDate || undefined}
|
maximumDate={endDate || undefined}
|
||||||
|
themeVariant={colorScheme}
|
||||||
|
textColor={colors.text}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -119,14 +154,14 @@ export default function DateRangePicker({
|
|||||||
{showEndPicker && (
|
{showEndPicker && (
|
||||||
<Modal transparent animationType="fade" visible={showEndPicker}>
|
<Modal transparent animationType="fade" visible={showEndPicker}>
|
||||||
<View style={styles.modalOverlay}>
|
<View style={styles.modalOverlay}>
|
||||||
<View style={styles.pickerContainer}>
|
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
|
||||||
<View style={styles.pickerHeader}>
|
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
|
||||||
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||||
<Text style={styles.cancelButton}>Hủy</Text>
|
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>{t("common.cancel")}</Text>
|
||||||
</TouchableOpacity>
|
</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)}>
|
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
|
||||||
<Text style={styles.doneButton}>Xong</Text>
|
<Text style={styles.doneButton}>{t("diary.dateRangePicker.done")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -135,6 +170,8 @@ export default function DateRangePicker({
|
|||||||
display={Platform.OS === "ios" ? "spinner" : "default"}
|
display={Platform.OS === "ios" ? "spinner" : "default"}
|
||||||
onChange={handleEndDateChange}
|
onChange={handleEndDateChange}
|
||||||
minimumDate={startDate || undefined}
|
minimumDate={startDate || undefined}
|
||||||
|
themeVariant={colorScheme}
|
||||||
|
textColor={colors.text}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -151,7 +188,6 @@ const styles = StyleSheet.create({
|
|||||||
label: {
|
label: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#111827",
|
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
@@ -166,25 +202,19 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
dateInput: {
|
dateInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#D1D5DB",
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
dateText: {
|
dateText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
placeholder: {
|
|
||||||
color: "#9CA3AF",
|
|
||||||
},
|
|
||||||
arrow: {
|
arrow: {
|
||||||
marginHorizontal: 4,
|
marginHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -197,7 +227,6 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
pickerContainer: {
|
pickerContainer: {
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderTopLeftRadius: 20,
|
borderTopLeftRadius: 20,
|
||||||
borderTopRightRadius: 20,
|
borderTopRightRadius: 20,
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
@@ -209,12 +238,10 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "#F3F4F6",
|
|
||||||
},
|
},
|
||||||
pickerTitle: {
|
pickerTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -223,7 +250,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#6B7280",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -241,3 +267,4 @@ const styles = StyleSheet.create({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, Text, StyleSheet, Platform } from "react-native";
|
import { TouchableOpacity, Text, StyleSheet, Platform } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
|
||||||
interface FilterButtonProps {
|
interface FilterButtonProps {
|
||||||
onPress?: () => void;
|
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 (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={[styles.button, themedStyles.button]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons name="filter" size={20} color="#374151" />
|
<Ionicons
|
||||||
<Text style={styles.text}>Bộ lọc</Text>
|
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>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -24,12 +57,10 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#E5E7EB",
|
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
@@ -42,7 +73,6 @@ const styles = StyleSheet.create({
|
|||||||
text: {
|
text: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "#374151",
|
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
|
|||||||
@@ -11,7 +11,34 @@ import {
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import StatusDropdown from "./StatusDropdown";
|
import StatusDropdown from "./StatusDropdown";
|
||||||
import DateRangePicker from "./DateRangePicker";
|
import DateRangePicker from "./DateRangePicker";
|
||||||
|
import ShipDropdown from "./ShipDropdown";
|
||||||
import { TripStatus } from "./types";
|
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 {
|
interface FilterModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -19,10 +46,16 @@ interface FilterModalProps {
|
|||||||
onApply: (filters: FilterValues) => void;
|
onApply: (filters: FilterValues) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShipOption {
|
||||||
|
id: string;
|
||||||
|
shipName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FilterValues {
|
export interface FilterValues {
|
||||||
status: TripStatus | null;
|
status: TripStatus | null; // number (0-5) hoặc null
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
|
selectedShip: ShipOption | null; // Tàu được chọn
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilterModal({
|
export default function FilterModal({
|
||||||
@@ -30,22 +63,67 @@ export default function FilterModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onApply,
|
onApply,
|
||||||
}: FilterModalProps) {
|
}: FilterModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const mapStatusNumberToString = useMapStatusNumberToString();
|
||||||
const [status, setStatus] = useState<TripStatus | null>(null);
|
const [status, setStatus] = useState<TripStatus | null>(null);
|
||||||
const [startDate, setStartDate] = useState<Date | null>(null);
|
const [startDate, setStartDate] = useState<Date | null>(null);
|
||||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||||
|
const [selectedShip, setSelectedShip] = useState<ShipOption | null>(null);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setStartDate(null);
|
setStartDate(null);
|
||||||
setEndDate(null);
|
setEndDate(null);
|
||||||
|
setSelectedShip(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply({ status, startDate, endDate });
|
onApply({ status, startDate, endDate, selectedShip });
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -60,16 +138,16 @@ export default function FilterModal({
|
|||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.modalContainer}
|
style={[styles.modalContainer, themedStyles.modalContainer]}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={[styles.header, themedStyles.header]}>
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
<Ionicons name="close" size={24} color="#111827" />
|
<Ionicons name="close" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={styles.title}>Bộ lọc</Text>
|
<Text style={[styles.title, themedStyles.title]}>{t("diary.filter")}</Text>
|
||||||
<View style={styles.placeholder} />
|
<View style={styles.placeholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -85,29 +163,37 @@ export default function FilterModal({
|
|||||||
onStartDateChange={setStartDate}
|
onStartDateChange={setStartDate}
|
||||||
onEndDateChange={setEndDate}
|
onEndDateChange={setEndDate}
|
||||||
/>
|
/>
|
||||||
|
<ShipDropdown value={selectedShip} onChange={setSelectedShip} />
|
||||||
|
|
||||||
{/* Filter Results Preview */}
|
{/* Filter Results Preview */}
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<View style={styles.previewContainer}>
|
<View style={[styles.previewContainer, themedStyles.previewContainer]}>
|
||||||
<Text style={styles.previewTitle}>Bộ lọc đã chọn:</Text>
|
<Text style={[styles.previewTitle, themedStyles.previewTitle]}>{t("diary.selectedFilters")}</Text>
|
||||||
{status && (
|
{status !== null && (
|
||||||
<View style={styles.filterTag}>
|
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||||
<Text style={styles.filterTagText}>
|
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||||
Trạng thái: {status}
|
{t("diary.statusLabel")} {mapStatusNumberToString(status)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{startDate && (
|
{startDate && (
|
||||||
<View style={styles.filterTag}>
|
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||||
<Text style={styles.filterTagText}>
|
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||||
Từ: {startDate.toLocaleDateString("vi-VN")}
|
{t("diary.fromLabel")} {startDate.toLocaleDateString("vi-VN")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{endDate && (
|
{endDate && (
|
||||||
<View style={styles.filterTag}>
|
<View style={[styles.filterTag, themedStyles.filterTag]}>
|
||||||
<Text style={styles.filterTagText}>
|
<Text style={[styles.filterTagText, themedStyles.filterTagText]}>
|
||||||
Đến: {endDate.toLocaleDateString("vi-VN")}
|
{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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -116,20 +202,20 @@ export default function FilterModal({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.footer}>
|
<View style={[styles.footer, themedStyles.footer]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.resetButton}
|
style={[styles.resetButton, themedStyles.resetButton]}
|
||||||
onPress={handleReset}
|
onPress={handleReset}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.resetButtonText}>Đặt lại</Text>
|
<Text style={[styles.resetButtonText, themedStyles.resetButtonText]}>{t("diary.reset")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.applyButton}
|
style={[styles.applyButton, themedStyles.applyButton]}
|
||||||
onPress={handleApply}
|
onPress={handleApply}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={styles.applyButtonText}>Áp dụng</Text>
|
<Text style={styles.applyButtonText}>{t("diary.apply")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -138,6 +224,7 @@ export default function FilterModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -145,7 +232,6 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
modalContainer: {
|
modalContainer: {
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
maxHeight: "80%",
|
maxHeight: "80%",
|
||||||
@@ -165,7 +251,6 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "#F3F4F6",
|
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: 4,
|
padding: 4,
|
||||||
@@ -173,7 +258,6 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -189,13 +273,11 @@ const styles = StyleSheet.create({
|
|||||||
previewContainer: {
|
previewContainer: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
backgroundColor: "#F9FAFB",
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
previewTitle: {
|
previewTitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#6B7280",
|
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
@@ -204,7 +286,6 @@ const styles = StyleSheet.create({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
filterTag: {
|
filterTag: {
|
||||||
backgroundColor: "#EFF6FF",
|
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
@@ -213,7 +294,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
filterTagText: {
|
filterTagText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#3B82F6",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -225,11 +305,9 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
padding: 20,
|
padding: 20,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: "#F3F4F6",
|
|
||||||
},
|
},
|
||||||
resetButton: {
|
resetButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#F3F4F6",
|
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -237,7 +315,6 @@ const styles = StyleSheet.create({
|
|||||||
resetButtonText: {
|
resetButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#6B7280",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -246,7 +323,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
applyButton: {
|
applyButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "#3B82F6",
|
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: "center",
|
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,
|
ScrollView,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 {
|
interface StatusDropdownProps {
|
||||||
value: TripStatus | null;
|
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({
|
export default function StatusDropdown({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: StatusDropdownProps) {
|
}: StatusDropdownProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useThemeContext();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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 =
|
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) => {
|
const handleSelect = (status: TripStatus | null) => {
|
||||||
onChange(status);
|
onChange(status);
|
||||||
setIsOpen(false);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<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
|
<TouchableOpacity
|
||||||
style={styles.selector}
|
style={[styles.selector, themedStyles.selector]}
|
||||||
onPress={() => setIsOpen(true)}
|
onPress={() => setIsOpen(true)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text style={[styles.selectorText, !value && styles.placeholder]}>
|
<Text style={[styles.selectorText, themedStyles.selectorText, !value && themedStyles.placeholder]}>
|
||||||
{selectedLabel}
|
{selectedLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="ellipsis-horizontal" size={20} color="#6B7280" />
|
<Ionicons name="ellipsis-horizontal" size={20} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -63,27 +80,28 @@ export default function StatusDropdown({
|
|||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={() => setIsOpen(false)}
|
onPress={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.modalContent}>
|
<View style={[styles.modalContent, themedStyles.modalContent]}>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
{STATUS_OPTIONS.map((option, index) => (
|
{STATUS_OPTIONS.map((option, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={index}
|
key={index}
|
||||||
style={[
|
style={[
|
||||||
styles.option,
|
styles.option,
|
||||||
value === option.value && styles.selectedOption,
|
themedStyles.option,
|
||||||
|
value === option.value && themedStyles.selectedOption,
|
||||||
]}
|
]}
|
||||||
onPress={() => handleSelect(option.value)}
|
onPress={() => handleSelect(option.value)}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.optionText,
|
styles.optionText,
|
||||||
value === option.value && styles.selectedOptionText,
|
themedStyles.optionText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
{value === option.value && (
|
{value === option.value && (
|
||||||
<Ionicons name="checkmark" size={20} color="#3B82F6" />
|
<Ionicons name="checkmark" size={20} color={colors.primary} />
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -102,7 +120,6 @@ const styles = StyleSheet.create({
|
|||||||
label: {
|
label: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#111827",
|
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
@@ -114,25 +131,19 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#D1D5DB",
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
selectorText: {
|
selectorText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
placeholder: {
|
|
||||||
color: "#9CA3AF",
|
|
||||||
},
|
|
||||||
modalOverlay: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
@@ -140,7 +151,6 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
modalContent: {
|
modalContent: {
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
width: "80%",
|
width: "80%",
|
||||||
maxHeight: "60%",
|
maxHeight: "60%",
|
||||||
@@ -161,23 +171,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
paddingVertical: 16,
|
paddingVertical: 16,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: "#F3F4F6",
|
|
||||||
|
|
||||||
},
|
|
||||||
selectedOption: {
|
|
||||||
backgroundColor: "#EFF6FF",
|
|
||||||
},
|
},
|
||||||
optionText: {
|
optionText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: "#111827",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
selectedOptionText: {
|
|
||||||
color: "#3B82F6",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,102 +7,221 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 {
|
interface TripCardProps {
|
||||||
trip: Trip;
|
trip: Model.Trip;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
onView?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onTeam?: () => void;
|
||||||
|
onSend?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TripCard({ trip, onPress }: TripCardProps) {
|
export default function TripCard({
|
||||||
const statusConfig = TRIP_STATUS_CONFIG[trip.status];
|
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 (
|
return (
|
||||||
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.7}>
|
<View style={[styles.card, themedStyles.card]}>
|
||||||
{/* Header */}
|
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||||
<View style={styles.header}>
|
{/* Header */}
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.header}>
|
||||||
<Ionicons
|
<View style={styles.headerLeft}>
|
||||||
name={statusConfig.icon as any}
|
<Ionicons
|
||||||
size={24}
|
name={statusConfig.icon as any}
|
||||||
color={statusConfig.textColor}
|
size={24}
|
||||||
/>
|
color={statusConfig.textColor}
|
||||||
<View style={styles.titleContainer}>
|
/>
|
||||||
<Text style={styles.title}>{trip.title}</Text>
|
<View style={styles.titleContainer}>
|
||||||
<Text style={styles.code}>{trip.code}</Text>
|
<Text style={[styles.title, themedStyles.title]}>{trip.name}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.badge,
|
|
||||||
{
|
|
||||||
backgroundColor: statusConfig.bgColor,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
style={[
|
||||||
styles.badgeText,
|
styles.badge,
|
||||||
{
|
{
|
||||||
color: statusConfig.textColor,
|
backgroundColor: statusConfig.bgColor,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{statusConfig.label}
|
<Text
|
||||||
</Text>
|
style={[
|
||||||
|
styles.badgeText,
|
||||||
|
{
|
||||||
|
color: statusConfig.textColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{statusConfig.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<View style={styles.infoGrid}>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<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, 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, 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>
|
||||||
|
</View>
|
||||||
{/* 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>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={styles.label}>Khởi hành</Text>
|
|
||||||
<Text style={styles.value}>{trip.departureDate}</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>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: "#FFFFFF",
|
borderRadius: 8,
|
||||||
borderRadius: 12,
|
padding: 12,
|
||||||
padding: 16,
|
marginBottom: 8,
|
||||||
marginBottom: 12,
|
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: {
|
shadowOffset: {
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 2,
|
height: 1,
|
||||||
},
|
},
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 8,
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#F3F4F6",
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
marginBottom: 16,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -110,13 +229,12 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
marginLeft: 12,
|
marginLeft: 8,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#111827",
|
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
@@ -124,19 +242,11 @@ const styles = StyleSheet.create({
|
|||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
code: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: "#6B7280",
|
|
||||||
fontFamily: Platform.select({
|
|
||||||
ios: "System",
|
|
||||||
android: "Roboto",
|
|
||||||
default: "System",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
badge: {
|
badge: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 8,
|
||||||
paddingVertical: 4,
|
paddingVertical: 2,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
badgeText: {
|
badgeText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -148,16 +258,16 @@ const styles = StyleSheet.create({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
infoGrid: {
|
infoGrid: {
|
||||||
gap: 12,
|
gap: 6,
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
infoRow: {
|
infoRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#6B7280",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
@@ -166,16 +276,37 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: "#111827",
|
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
textAlign: "right",
|
|
||||||
fontFamily: Platform.select({
|
fontFamily: Platform.select({
|
||||||
ios: "System",
|
ios: "System",
|
||||||
android: "Roboto",
|
android: "Roboto",
|
||||||
default: "System",
|
default: "System",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
duration: {
|
divider: {
|
||||||
color: "#3B82F6",
|
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 =
|
export type TripStatus =
|
||||||
| "completed"
|
| 0 // Đã khởi tạo
|
||||||
| "in-progress"
|
| 1 // Chờ duyệt
|
||||||
| "cancelled"
|
| 2 // Đã duyệt
|
||||||
| "quality-check";
|
| 3 // Đang hoạt động
|
||||||
|
| 4 // Hoàn thành
|
||||||
export interface Trip {
|
| 5; // Đã hủy
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
code: string;
|
|
||||||
vessel: string;
|
|
||||||
vesselCode: string;
|
|
||||||
departureDate: string;
|
|
||||||
returnDate: string | null;
|
|
||||||
duration: string;
|
|
||||||
status: TripStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Static config - dùng khi không cần i18n hoặc ngoài React component
|
||||||
export const TRIP_STATUS_CONFIG = {
|
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",
|
label: "Hoàn thành",
|
||||||
bgColor: "#D1FAE5",
|
bgColor: "#D1FAE5", // Green background
|
||||||
textColor: "#065F46",
|
textColor: "#065F46", // Dark green text
|
||||||
icon: "checkmark-circle",
|
icon: "checkmark-circle",
|
||||||
},
|
},
|
||||||
"in-progress": {
|
5: {
|
||||||
label: "Đang diễn ra",
|
|
||||||
bgColor: "#DBEAFE",
|
|
||||||
textColor: "#1E40AF",
|
|
||||||
icon: "time",
|
|
||||||
},
|
|
||||||
cancelled: {
|
|
||||||
label: "Đã hủy",
|
label: "Đã hủy",
|
||||||
bgColor: "#FEE2E2",
|
bgColor: "#FEE2E2", // Red background
|
||||||
textColor: "#991B1B",
|
textColor: "#991B1B", // Dark red text
|
||||||
icon: "close-circle",
|
icon: "close-circle",
|
||||||
},
|
},
|
||||||
"quality-check": {
|
|
||||||
label: "Khảo sát địa chất",
|
|
||||||
bgColor: "#D1FAE5",
|
|
||||||
textColor: "#065F46",
|
|
||||||
icon: "checkmark-circle",
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Hook để lấy config với i18n - dùng trong React component
|
||||||
|
export function useTripStatusConfig() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return {
|
||||||
|
0: {
|
||||||
|
label: t("diary.statusDropdown.created"),
|
||||||
|
bgColor: "#F3F4F6",
|
||||||
|
textColor: "#4B5563",
|
||||||
|
icon: "document-text",
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
label: t("diary.statusDropdown.pending"),
|
||||||
|
bgColor: "#FEF3C7",
|
||||||
|
textColor: "#92400E",
|
||||||
|
icon: "hourglass",
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
label: t("diary.statusDropdown.approved"),
|
||||||
|
bgColor: "#E0E7FF",
|
||||||
|
textColor: "#3730A3",
|
||||||
|
icon: "checkmark-done",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
label: t("diary.statusDropdown.active"),
|
||||||
|
bgColor: "#DBEAFE",
|
||||||
|
textColor: "#1E40AF",
|
||||||
|
icon: "sync",
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
label: t("diary.statusDropdown.completed"),
|
||||||
|
bgColor: "#D1FAE5",
|
||||||
|
textColor: "#065F46",
|
||||||
|
icon: "checkmark-circle",
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
label: t("diary.statusDropdown.cancelled"),
|
||||||
|
bgColor: "#FEE2E2",
|
||||||
|
textColor: "#991B1B",
|
||||||
|
icon: "close-circle",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ThemedText } from "@/components/themed-text";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { useAppTheme } from "@/hooks/use-app-theme";
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
import { View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
|
|
||||||
interface DescriptionProps {
|
interface DescriptionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -13,12 +13,14 @@ export const Description = ({
|
|||||||
const { colors } = useAppTheme();
|
const { colors } = useAppTheme();
|
||||||
return (
|
return (
|
||||||
<View className="flex-row gap-2 ">
|
<View className="flex-row gap-2 ">
|
||||||
<ThemedText
|
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||||
style={{ color: colors.textSecondary, fontSize: 16 }}
|
|
||||||
>
|
|
||||||
{title}:
|
{title}:
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
<ThemedText style={{ fontSize: 16 }}>{description}</ThemedText>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<ThemedText style={{ color: colors.textSecondary, fontSize: 16 }}>
|
||||||
|
{description || "-"}
|
||||||
|
</ThemedText>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
141
components/map/ShipInfo.tsx
Normal file
141
components/map/ShipInfo.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { STATUS_DANGEROUS, STATUS_NORMAL, STATUS_WARNING } from "@/constants";
|
||||||
|
import { useAppTheme } from "@/hooks/use-app-theme";
|
||||||
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
|
import { formatRelativeTime } from "@/services/time_service";
|
||||||
|
import { convertToDMS, kmhToKnot } from "@/utils/geom";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { ScrollView, Text, View } from "react-native";
|
||||||
|
import { ThemedText } from "../themed-text";
|
||||||
|
|
||||||
|
interface ShipInfoProps {
|
||||||
|
thingMetadata?: Model.ThingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShipInfo = ({ thingMetadata }: ShipInfoProps) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
// Định nghĩa màu sắc theo trạng thái
|
||||||
|
const statusConfig = {
|
||||||
|
normal: {
|
||||||
|
dotColor: "bg-green-500",
|
||||||
|
badgeColor: "bg-green-100",
|
||||||
|
badgeTextColor: "text-green-700",
|
||||||
|
badgeText: "Bình thường",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
dotColor: "bg-yellow-500",
|
||||||
|
badgeColor: "bg-yellow-100",
|
||||||
|
badgeTextColor: "text-yellow-700",
|
||||||
|
badgeText: "Cảnh báo",
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
dotColor: "bg-red-500",
|
||||||
|
badgeColor: "bg-red-100",
|
||||||
|
badgeTextColor: "text-red-700",
|
||||||
|
badgeText: "Nguy hiểm",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const getThingStatus = () => {
|
||||||
|
switch (thingMetadata?.state_level) {
|
||||||
|
case STATUS_NORMAL:
|
||||||
|
return "normal";
|
||||||
|
case STATUS_WARNING:
|
||||||
|
return "warning";
|
||||||
|
case STATUS_DANGEROUS:
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "normal";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gpsData: Model.GPSResponse = JSON.parse(thingMetadata?.gps || "{}");
|
||||||
|
|
||||||
|
const currentStatus = statusConfig[getThingStatus()];
|
||||||
|
|
||||||
|
// Format tọa độ
|
||||||
|
const formatCoordinate = (lat: number, lon: number) => {
|
||||||
|
const latDir = lat >= 0 ? "N" : "S";
|
||||||
|
const lonDir = lon >= 0 ? "E" : "W";
|
||||||
|
return `${Math.abs(lat).toFixed(4)}°${latDir}, ${Math.abs(lon).toFixed(
|
||||||
|
4
|
||||||
|
)}°${lonDir}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 py-3">
|
||||||
|
{/* Header: Tên tàu và trạng thái */}
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
{/* Status dot */}
|
||||||
|
<View className={`h-3 w-3 rounded-full ${currentStatus.dotColor}`} />
|
||||||
|
|
||||||
|
{/* Tên tàu */}
|
||||||
|
<Text className="text-lg font-semibold text-gray-900">
|
||||||
|
{thingMetadata?.ship_name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Badge trạng thái */}
|
||||||
|
<View className={`px-3 py-1 rounded-full ${currentStatus.badgeColor}`}>
|
||||||
|
<Text
|
||||||
|
className={`text-md font-medium ${currentStatus.badgeTextColor}`}
|
||||||
|
>
|
||||||
|
{currentStatus.badgeText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Mã tàu */}
|
||||||
|
{/* <Text className="text-base text-gray-600 mb-2">{shipCode}</Text> */}
|
||||||
|
<View className="flex-row items-center justify-between gap-2 mb-3">
|
||||||
|
<View className="flex-row items-center gap-2 w-2/3">
|
||||||
|
<Ionicons name="speedometer-outline" size={16} color="#6B7280" />
|
||||||
|
<Text className="text-md text-gray-600">
|
||||||
|
{kmhToKnot(gpsData.s || 0)} {t("home.speed_units")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-start gap-2 w-1/3 ">
|
||||||
|
<Ionicons name="compass-outline" size={16} color="#6B7280" />
|
||||||
|
<Text className="text-md text-gray-600">{gpsData.h}°</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* Tọa độ */}
|
||||||
|
<View className="flex-row items-center justify-between gap-2 mb-2">
|
||||||
|
<View className="flex-row items-center gap-2 w-2/3">
|
||||||
|
<Ionicons name="location-outline" size={16} color="#6B7280" />
|
||||||
|
<Text className="text-md text-gray-600">
|
||||||
|
{convertToDMS(gpsData.lat || 0, true)},
|
||||||
|
{convertToDMS(gpsData.lon || 0, false)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className=" flex-row items-start gap-2 w-1/3 ">
|
||||||
|
<Ionicons name="time-outline" size={16} color="#6B7280" />
|
||||||
|
<Text className="text-md text-gray-600">
|
||||||
|
{formatRelativeTime(gpsData.t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* <View className="flex">
|
||||||
|
<Description title="Trạng thái" description={thingMetadata?.state} />
|
||||||
|
</View> */}
|
||||||
|
{thingMetadata?.state !== "" && (
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<ThemedText style={{ color: colors.textSecondary, fontSize: 15 }}>
|
||||||
|
Trạng thái:
|
||||||
|
</ThemedText>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<ThemedText style={{ color: colors.text, fontSize: 15 }}>
|
||||||
|
{thingMetadata?.state || "-"}
|
||||||
|
</ThemedText>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShipInfo;
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import {
|
|
||||||
queryDeleteSos,
|
|
||||||
queryGetSos,
|
|
||||||
querySendSosMessage,
|
|
||||||
} from "@/controller/DeviceController";
|
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import { showErrorToast } from "@/services/toast_service";
|
|
||||||
import { sosMessage } from "@/utils/sosUtils";
|
|
||||||
import { MaterialIcons } from "@expo/vector-icons";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
|
||||||
import IconButton from "../IconButton";
|
|
||||||
import Select from "../Select";
|
|
||||||
import Modal from "../ui/modal";
|
|
||||||
import { useThemeColor } from "@/hooks/use-theme-color";
|
|
||||||
|
|
||||||
const SosButton = () => {
|
|
||||||
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
|
||||||
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
|
|
||||||
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [customMessage, setCustomMessage] = useState("");
|
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
// Theme colors
|
|
||||||
const textColor = useThemeColor({}, 'text');
|
|
||||||
const borderColor = useThemeColor({}, 'border');
|
|
||||||
const errorColor = useThemeColor({}, 'error');
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
|
|
||||||
// Dynamic styles
|
|
||||||
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
|
|
||||||
|
|
||||||
const sosOptions = [
|
|
||||||
...sosMessage.map((msg) => ({
|
|
||||||
ma: msg.ma,
|
|
||||||
moTa: msg.moTa,
|
|
||||||
label: msg.moTa,
|
|
||||||
value: msg.ma,
|
|
||||||
})),
|
|
||||||
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const getSosData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await queryGetSos();
|
|
||||||
// console.log("SoS ResponseL: ", response);
|
|
||||||
|
|
||||||
setSosData(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch SOS data:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getSosData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
const newErrors: { [key: string]: string } = {};
|
|
||||||
|
|
||||||
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
|
||||||
newErrors.customMessage = t("home.sos.statusRequired");
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmSos = async () => {
|
|
||||||
if (!validateForm()) {
|
|
||||||
console.log("Form chưa validate");
|
|
||||||
return; // Không đóng modal nếu validate fail
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageToSend = "";
|
|
||||||
if (selectedSosMessage === 999) {
|
|
||||||
messageToSend = customMessage.trim();
|
|
||||||
} else {
|
|
||||||
const selectedOption = sosOptions.find(
|
|
||||||
(opt) => opt.ma === selectedSosMessage
|
|
||||||
);
|
|
||||||
messageToSend = selectedOption ? selectedOption.moTa : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gửi dữ liệu đi
|
|
||||||
await sendSosMessage(messageToSend);
|
|
||||||
|
|
||||||
// Đóng modal và reset form sau khi gửi thành công
|
|
||||||
setShowConfirmSosDialog(false);
|
|
||||||
setSelectedSosMessage(null);
|
|
||||||
setCustomMessage("");
|
|
||||||
setErrors({});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickButton = async (isActive: boolean) => {
|
|
||||||
console.log("Is Active: ", isActive);
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
const resp = await queryDeleteSos();
|
|
||||||
if (resp.status === 200) {
|
|
||||||
await getSosData();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
|
|
||||||
setShowConfirmSosDialog(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendSosMessage = async (message: string) => {
|
|
||||||
try {
|
|
||||||
const resp = await querySendSosMessage(message);
|
|
||||||
if (resp.status === 200) {
|
|
||||||
await getSosData();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error when send sos: ", error);
|
|
||||||
showErrorToast(t("home.sos.sendError"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<IconButton
|
|
||||||
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
|
||||||
type="danger"
|
|
||||||
size="middle"
|
|
||||||
onPress={() => handleClickButton(sosData?.active || false)}
|
|
||||||
style={{ borderRadius: 20 }}
|
|
||||||
>
|
|
||||||
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
|
||||||
</IconButton>
|
|
||||||
<Modal
|
|
||||||
open={showConfirmSosDialog}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowConfirmSosDialog(false);
|
|
||||||
setSelectedSosMessage(null);
|
|
||||||
setCustomMessage("");
|
|
||||||
setErrors({});
|
|
||||||
}}
|
|
||||||
okText={t("home.sos.confirm")}
|
|
||||||
cancelText={t("home.sos.cancel")}
|
|
||||||
title={t("home.sos.title")}
|
|
||||||
centered
|
|
||||||
onOk={handleConfirmSos}
|
|
||||||
>
|
|
||||||
{/* Select Nội dung SOS */}
|
|
||||||
<View style={styles.formGroup}>
|
|
||||||
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={selectedSosMessage ?? undefined}
|
|
||||||
options={sosOptions}
|
|
||||||
placeholder={t("home.sos.selectReason")}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSelectedSosMessage(value as number);
|
|
||||||
// Clear custom message nếu chọn khác lý do
|
|
||||||
if (value !== 999) {
|
|
||||||
setCustomMessage("");
|
|
||||||
}
|
|
||||||
// Clear error if exists
|
|
||||||
if (errors.sosMessage) {
|
|
||||||
setErrors((prev) => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors.sosMessage;
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showSearch={false}
|
|
||||||
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
|
||||||
/>
|
|
||||||
{errors.sosMessage && (
|
|
||||||
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Input Custom Message nếu chọn "Khác" */}
|
|
||||||
{selectedSosMessage === 999 && (
|
|
||||||
<View style={styles.formGroup}>
|
|
||||||
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.input,
|
|
||||||
errors.customMessage ? styles.errorInput : {},
|
|
||||||
]}
|
|
||||||
placeholder={t("home.sos.enterStatus")}
|
|
||||||
placeholderTextColor={textColor + '99'} // Add transparency
|
|
||||||
value={customMessage}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setCustomMessage(text);
|
|
||||||
if (text.trim() !== "") {
|
|
||||||
setErrors((prev) => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors.customMessage;
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
/>
|
|
||||||
{errors.customMessage && (
|
|
||||||
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
|
||||||
formGroup: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
marginBottom: 8,
|
|
||||||
color: textColor,
|
|
||||||
},
|
|
||||||
errorBorder: {
|
|
||||||
borderColor: errorColor,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: borderColor,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
color: textColor,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
textAlignVertical: "top",
|
|
||||||
},
|
|
||||||
errorInput: {
|
|
||||||
borderColor: errorColor,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: errorColor,
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SosButton;
|
|
||||||
165
components/map/TagState.tsx
Normal file
165
components/map/TagState.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, ScrollView, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
import { ThemedText } from "../themed-text";
|
||||||
|
|
||||||
|
export type TagStateCallbackPayload = {
|
||||||
|
isNormal: boolean;
|
||||||
|
isWarning: boolean;
|
||||||
|
isDangerous: boolean;
|
||||||
|
isSos: boolean;
|
||||||
|
isDisconected: boolean; // giữ đúng theo yêu cầu (1 'n')
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagStateProps = {
|
||||||
|
normalCount?: number;
|
||||||
|
warningCount?: number;
|
||||||
|
dangerousCount?: number;
|
||||||
|
sosCount?: number;
|
||||||
|
disconnectedCount?: number;
|
||||||
|
onTagPress?: (selection: TagStateCallbackPayload) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TagState({
|
||||||
|
normalCount = 0,
|
||||||
|
warningCount = 0,
|
||||||
|
dangerousCount = 0,
|
||||||
|
sosCount = 0,
|
||||||
|
disconnectedCount = 0,
|
||||||
|
onTagPress,
|
||||||
|
className = "",
|
||||||
|
}: TagStateProps) {
|
||||||
|
// Quản lý trạng thái các tag ở cấp cha để trả về tổng hợp
|
||||||
|
const [activeStates, setActiveStates] = useState({
|
||||||
|
normal: false,
|
||||||
|
warning: false,
|
||||||
|
dangerous: false,
|
||||||
|
sos: false,
|
||||||
|
disconnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleState = (state: keyof typeof activeStates) => {
|
||||||
|
setActiveStates((prev) => {
|
||||||
|
const next = { ...prev, [state]: !prev[state] };
|
||||||
|
// Gọi callback với object tổng hợp sau khi cập nhật
|
||||||
|
onTagPress?.({
|
||||||
|
isNormal: next.normal,
|
||||||
|
isWarning: next.warning,
|
||||||
|
isDangerous: next.dangerous,
|
||||||
|
isSos: next.sos,
|
||||||
|
isDisconected: next.disconnected,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTag = (
|
||||||
|
state: "normal" | "warning" | "dangerous" | "sos" | "disconnected",
|
||||||
|
count: number,
|
||||||
|
label: string,
|
||||||
|
colors: {
|
||||||
|
defaultBg: string;
|
||||||
|
defaultBorder: string;
|
||||||
|
defaultText: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeBorder: string;
|
||||||
|
activeText: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (count === 0) return null;
|
||||||
|
const pressed = activeStates[state];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={state}
|
||||||
|
onPress={() => toggleState(state)}
|
||||||
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{
|
||||||
|
backgroundColor: pressed ? colors.activeBg : colors.defaultBg,
|
||||||
|
borderColor: pressed ? colors.activeBorder : colors.defaultBorder,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.tagText,
|
||||||
|
{
|
||||||
|
color: pressed ? colors.activeText : colors.defaultText,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
type="defaultSemiBold"
|
||||||
|
>
|
||||||
|
{label}: {count}
|
||||||
|
</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className={className} style={styles.container}>
|
||||||
|
{renderTag("normal", normalCount, "Bình thường", {
|
||||||
|
defaultBg: "#FFFFFF",
|
||||||
|
defaultBorder: "#22C55E",
|
||||||
|
defaultText: "#22C55E",
|
||||||
|
activeBg: "#22C55E",
|
||||||
|
activeBorder: "#22C55E",
|
||||||
|
activeText: "#FFFFFF",
|
||||||
|
})}
|
||||||
|
{renderTag("warning", warningCount, "Cảnh báo", {
|
||||||
|
defaultBg: "#FFFFFF",
|
||||||
|
defaultBorder: "#EAB308",
|
||||||
|
defaultText: "#EAB308",
|
||||||
|
activeBg: "#EAB308",
|
||||||
|
activeBorder: "#EAB308",
|
||||||
|
activeText: "#FFFFFF",
|
||||||
|
})}
|
||||||
|
{renderTag("dangerous", dangerousCount, "Nguy hiểm", {
|
||||||
|
defaultBg: "#FFFFFF",
|
||||||
|
defaultBorder: "#F97316",
|
||||||
|
defaultText: "#F97316",
|
||||||
|
activeBg: "#F97316",
|
||||||
|
activeBorder: "#F97316",
|
||||||
|
activeText: "#FFFFFF",
|
||||||
|
})}
|
||||||
|
{renderTag("sos", sosCount, "SOS", {
|
||||||
|
defaultBg: "#FFFFFF",
|
||||||
|
defaultBorder: "#EF4444",
|
||||||
|
defaultText: "#EF4444",
|
||||||
|
activeBg: "#EF4444",
|
||||||
|
activeBorder: "#EF4444",
|
||||||
|
activeText: "#FFFFFF",
|
||||||
|
})}
|
||||||
|
{renderTag("disconnected", disconnectedCount, "Mất kết nối", {
|
||||||
|
defaultBg: "#FFFFFF",
|
||||||
|
defaultBorder: "#6B7280",
|
||||||
|
defaultText: "#6B7280",
|
||||||
|
activeBg: "#6B7280",
|
||||||
|
activeBorder: "#6B7280",
|
||||||
|
activeText: "#FFFFFF",
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
minWidth: 100,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
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",
|
xmark: "close",
|
||||||
pencil: "edit",
|
pencil: "edit",
|
||||||
trash: "delete",
|
trash: "delete",
|
||||||
"square.stack.3d.up": "layers",
|
"square.stack.3d.up.fill": "layers",
|
||||||
"bell.fill": "notifications",
|
"bell.fill": "notifications",
|
||||||
} as IconMapping;
|
} as IconMapping;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const EVENT_ALARM_DATA = "ALARM_DATA_EVENT";
|
|||||||
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
export const EVENT_ENTITY_DATA = "ENTITY_DATA_EVENT";
|
||||||
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
export const EVENT_BANZONE_DATA = "BANZONE_DATA_EVENT";
|
||||||
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
export const EVENT_TRACK_POINTS_DATA = "TRACK_POINTS_DATA_EVENT";
|
||||||
|
export const EVENT_SEARCH_THINGS = "SEARCH_THINGS_EVENT";
|
||||||
|
|
||||||
// Entity Contants
|
// Entity Contants
|
||||||
export const ENTITY = {
|
export const ENTITY = {
|
||||||
@@ -28,13 +29,20 @@ export const ENTITY = {
|
|||||||
GPS: "50:1",
|
GPS: "50:1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STATUS_NORMAL = 0;
|
||||||
|
export const STATUS_WARNING = 1;
|
||||||
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
export const STATUS_SOS = 3;
|
||||||
|
|
||||||
// API Path Constants
|
// API Path Constants
|
||||||
export const API_PATH_LOGIN = "/api/tokens";
|
export const API_PATH_LOGIN = "/api/tokens";
|
||||||
|
export const API_PATH_SEARCH_THINGS = "/api/things/search";
|
||||||
export const API_PATH_ENTITIES = "/api/io/entities";
|
export const API_PATH_ENTITIES = "/api/io/entities";
|
||||||
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
|
export const API_PATH_SHIP_INFO = "/api/sgw/shipinfo";
|
||||||
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
|
export const API_GET_ALL_LAYER = "/api/sgw/geojsonlist";
|
||||||
export const API_GET_LAYER_INFO = "/api/sgw/geojson";
|
export const API_GET_LAYER_INFO = "/api/sgw/geojson";
|
||||||
export const API_GET_TRIP = "/api/sgw/trip";
|
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_GET_ALARMS = "/api/io/alarms";
|
||||||
export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
|
export const API_UPDATE_TRIP_STATUS = "/api/sgw/tripState";
|
||||||
export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
|
export const API_HAUL_HANDLE = "/api/sgw/fishingLog";
|
||||||
@@ -44,3 +52,8 @@ export const API_UPDATE_FISHING_LOGS = "/api/sgw/fishingLog";
|
|||||||
export const API_SOS = "/api/sgw/sos";
|
export const API_SOS = "/api/sgw/sos";
|
||||||
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
export const API_PATH_SHIP_TRACK_POINTS = "/api/sgw/trackpoints";
|
||||||
export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
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";
|
||||||
|
export const API_GET_ALARM = "/api/alarms";
|
||||||
|
export const API_MANAGER_ALARM = "/api/alarms/confirm";
|
||||||
|
|||||||
15
controller/AlarmController.ts
Normal file
15
controller/AlarmController.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_ALARM, API_MANAGER_ALARM } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryAlarms(payload: Model.AlarmPayload) {
|
||||||
|
return await api.get<Model.AlarmResponse>(API_GET_ALARM, {
|
||||||
|
params: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryConfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||||
|
return await api.post(API_MANAGER_ALARM, body);
|
||||||
|
}
|
||||||
|
export async function queryrUnconfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||||
|
return await api.delete(API_MANAGER_ALARM, { data: body });
|
||||||
|
}
|
||||||
@@ -1,37 +1,18 @@
|
|||||||
import { api } from "@/config";
|
import { api } from "@/config";
|
||||||
import {
|
import {
|
||||||
API_GET_ALARMS,
|
API_GET_SHIP_GROUPS,
|
||||||
API_GET_GPS,
|
API_GET_SHIP_TYPES,
|
||||||
API_PATH_ENTITIES,
|
API_PATH_SEARCH_THINGS,
|
||||||
API_PATH_SHIP_TRACK_POINTS,
|
|
||||||
API_SOS,
|
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import { transformEntityResponse } from "@/utils/tranform";
|
|
||||||
|
|
||||||
export async function queryGpsData() {
|
export async function querySearchThings(body: Model.SearchThingBody) {
|
||||||
return api.get<Model.GPSResponse>(API_GET_GPS);
|
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAlarm() {
|
export async function queryShipTypes() {
|
||||||
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
|
return await api.get<Model.ShipType[]>(API_GET_SHIP_TYPES);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryTrackPoints() {
|
export async function queryShipGroups() {
|
||||||
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
|
||||||
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
|
||||||
return response.data.map(transformEntityResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryGetSos() {
|
|
||||||
return await api.get<Model.SosResponse>(API_SOS);
|
|
||||||
}
|
|
||||||
export async function queryDeleteSos() {
|
|
||||||
return await api.delete<Model.SosResponse>(API_SOS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function querySendSosMessage(message: string) {
|
|
||||||
return await api.put<Model.SosRequest>(API_SOS, { message });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ import { API_GET_ALL_BANZONES } from "@/constants";
|
|||||||
export async function queryBanzones() {
|
export async function queryBanzones() {
|
||||||
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
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 {
|
import {
|
||||||
API_GET_TRIP,
|
API_GET_TRIP,
|
||||||
API_HAUL_HANDLE,
|
API_HAUL_HANDLE,
|
||||||
|
API_POST_TRIPSLIST,
|
||||||
API_UPDATE_FISHING_LOGS,
|
API_UPDATE_FISHING_LOGS,
|
||||||
API_UPDATE_TRIP_STATUS,
|
API_UPDATE_TRIP_STATUS,
|
||||||
|
API_GET_LAST_TRIP,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
|
|
||||||
export async function queryTrip() {
|
export async function queryTrip() {
|
||||||
return api.get<Model.Trip>(API_GET_TRIP);
|
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) {
|
export async function queryUpdateTripState(body: Model.TripUpdateStateRequest) {
|
||||||
return api.put(API_UPDATE_TRIP_STATUS, body);
|
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) {
|
export async function queryUpdateFishingLogs(body: Model.FishingLog) {
|
||||||
return api.put(API_UPDATE_FISHING_LOGS, body);
|
return api.put(API_UPDATE_FISHING_LOGS, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryTripsList(body: Model.TripListBody) {
|
||||||
|
return api.post(API_POST_TRIPSLIST, body);
|
||||||
|
}
|
||||||
|
|||||||
203
controller/typings.d.ts
vendored
203
controller/typings.d.ts
vendored
@@ -15,54 +15,22 @@ declare namespace Model {
|
|||||||
s: number;
|
s: number;
|
||||||
h: number;
|
h: number;
|
||||||
fishing: boolean;
|
fishing: boolean;
|
||||||
}
|
|
||||||
interface Alarm {
|
|
||||||
name: string;
|
|
||||||
t: number; // timestamp (epoch seconds)
|
|
||||||
level: number;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlarmResponse {
|
|
||||||
alarms: Alarm[];
|
|
||||||
level: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShipTrackPoint {
|
|
||||||
time: number;
|
|
||||||
lon: number;
|
|
||||||
lat: number;
|
|
||||||
s: number;
|
|
||||||
h: number;
|
|
||||||
}
|
|
||||||
interface EntityResponse {
|
|
||||||
id: string;
|
|
||||||
v: number;
|
|
||||||
vs: string;
|
|
||||||
t: number;
|
t: number;
|
||||||
type: string;
|
|
||||||
}
|
}
|
||||||
interface TransformedEntity {
|
|
||||||
id: string;
|
|
||||||
value: number;
|
|
||||||
valueString: string;
|
|
||||||
time: number;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Banzones
|
|
||||||
// Banzone
|
// Banzone
|
||||||
export interface Zone {
|
interface Zone {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: number;
|
type?: number;
|
||||||
conditions?: Condition[];
|
conditions?: Condition[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
updated_at?: Date;
|
updated_at?: Date;
|
||||||
geom?: Geom;
|
geometry?: string;
|
||||||
|
description?: string;
|
||||||
|
province_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Condition {
|
interface Condition {
|
||||||
max?: number;
|
max?: number;
|
||||||
min?: number;
|
min?: number;
|
||||||
type?: Type;
|
type?: Type;
|
||||||
@@ -70,12 +38,12 @@ declare namespace Model {
|
|||||||
from?: number;
|
from?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Type {
|
enum Type {
|
||||||
LengthLimit = "length_limit",
|
LengthLimit = "length_limit",
|
||||||
MonthRange = "month_range",
|
MonthRange = "month_range",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Geom {
|
interface Geom {
|
||||||
geom_type?: number;
|
geom_type?: number;
|
||||||
geom_poly?: string;
|
geom_poly?: string;
|
||||||
geom_lines?: string;
|
geom_lines?: string;
|
||||||
@@ -92,7 +60,37 @@ declare namespace Model {
|
|||||||
message?: string;
|
message?: string;
|
||||||
started_at?: number;
|
started_at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trip
|
// 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 {
|
interface Trip {
|
||||||
id: string;
|
id: string;
|
||||||
ship_id: string;
|
ship_id: string;
|
||||||
@@ -211,4 +209,131 @@ declare namespace Model {
|
|||||||
cites_appendix: any;
|
cites_appendix: any;
|
||||||
vn_law: boolean;
|
vn_law: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seagateway Owner App
|
||||||
|
// Thing
|
||||||
|
interface SearchThingBody {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
dir?: "asc" | "desc";
|
||||||
|
name?: string;
|
||||||
|
metadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponse {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
direction?: string;
|
||||||
|
metadata?: ThingsResponseMetadata;
|
||||||
|
things?: Thing[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponseMetadata {
|
||||||
|
total_connected?: number;
|
||||||
|
total_filter?: number;
|
||||||
|
total_sos?: number;
|
||||||
|
total_state_level_0?: number;
|
||||||
|
total_state_level_1?: number;
|
||||||
|
total_state_level_2?: number;
|
||||||
|
total_thing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Thing {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
metadata?: ThingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingMetadata {
|
||||||
|
address?: string;
|
||||||
|
alarm_list?: string;
|
||||||
|
basename?: string;
|
||||||
|
cfg_channel_id?: string;
|
||||||
|
cfg_id?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
ctrl_channel_id?: string;
|
||||||
|
data_channel_id?: string;
|
||||||
|
enduser?: string;
|
||||||
|
external_id?: string;
|
||||||
|
gps?: string;
|
||||||
|
gps_time?: string;
|
||||||
|
group_id?: string;
|
||||||
|
req_channel_id?: string;
|
||||||
|
ship_group_id?: string;
|
||||||
|
ship_id?: string;
|
||||||
|
ship_length?: string;
|
||||||
|
ship_name?: string;
|
||||||
|
ship_power?: string;
|
||||||
|
ship_reg_number?: string;
|
||||||
|
ship_type?: string;
|
||||||
|
sos?: string;
|
||||||
|
sos_time?: string;
|
||||||
|
state?: string;
|
||||||
|
state_level?: number;
|
||||||
|
state_updated_time?: number;
|
||||||
|
trip_state?: string;
|
||||||
|
type?: string;
|
||||||
|
updated_time?: number;
|
||||||
|
uptime?: number;
|
||||||
|
zone_approaching_alarm_list?: string;
|
||||||
|
zone_entered_alarm_list?: string;
|
||||||
|
zone_fishing_alarm_list?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship
|
||||||
|
interface ShipType {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroup {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmPayload {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
order?: string;
|
||||||
|
dir?: "asc" | "desc";
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmResponse {
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
dir?: string;
|
||||||
|
alarms?: Alarm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Alarm {
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
level?: number;
|
||||||
|
id?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confirmed_email?: string;
|
||||||
|
confirmed_time?: number;
|
||||||
|
confirmed_desc?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
thing_name?: string;
|
||||||
|
thing_type?: ThingType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmConfirmRequest {
|
||||||
|
id: string;
|
||||||
|
description?: string;
|
||||||
|
thing_id: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
locales/en.json
108
locales/en.json
@@ -4,6 +4,7 @@
|
|||||||
"footer_text": "Product of Mobifone v1.0",
|
"footer_text": "Product of Mobifone v1.0",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"done": "Done",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"latitude": "Latitude",
|
"latitude": "Latitude",
|
||||||
"longitude": "Longitude",
|
"longitude": "Longitude",
|
||||||
"speed": "Speed",
|
"speed": "Speed",
|
||||||
|
"speed_units": "knots",
|
||||||
"heading": "Heading",
|
"heading": "Heading",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -59,6 +61,112 @@
|
|||||||
"sendError": "Unable to send SOS signal"
|
"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": {
|
"trip": {
|
||||||
"infoTrip": "Trip Information",
|
"infoTrip": "Trip Information",
|
||||||
"createNewTrip": "Create New Trip",
|
"createNewTrip": "Create New Trip",
|
||||||
|
|||||||
108
locales/vi.json
108
locales/vi.json
@@ -4,6 +4,7 @@
|
|||||||
"footer_text": "Sản phẩm của Mobifone v1.0",
|
"footer_text": "Sản phẩm của Mobifone v1.0",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
|
"done": "Xong",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"edit": "Chỉnh sửa",
|
"edit": "Chỉnh sửa",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"latitude": "Vĩ độ",
|
"latitude": "Vĩ độ",
|
||||||
"longitude": "Kinh độ",
|
"longitude": "Kinh độ",
|
||||||
"speed": "Tốc độ",
|
"speed": "Tốc độ",
|
||||||
|
"speed_units": "hải lý/giờ",
|
||||||
"heading": "Hướng",
|
"heading": "Hướng",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"online": "Trực tuyến",
|
"online": "Trực tuyến",
|
||||||
@@ -59,6 +61,112 @@
|
|||||||
"sendError": "Không thể gửi tín hiệu SOS"
|
"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": {
|
"trip": {
|
||||||
"infoTrip": "Thông Tin Chuyến Đi",
|
"infoTrip": "Thông Tin Chuyến Đi",
|
||||||
"createNewTrip": "Tạo chuyến mới",
|
"createNewTrip": "Tạo chuyến mới",
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -41,7 +41,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.67.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
@@ -13295,9 +13295,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.0",
|
"version": "7.67.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-aria": "^3.44.0",
|
"react-aria": "^3.44.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.67.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
|
|||||||
@@ -1,135 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
AUTO_REFRESH_INTERVAL,
|
AUTO_REFRESH_INTERVAL,
|
||||||
EVENT_ALARM_DATA,
|
|
||||||
EVENT_BANZONE_DATA,
|
EVENT_BANZONE_DATA,
|
||||||
EVENT_ENTITY_DATA,
|
EVENT_SEARCH_THINGS,
|
||||||
EVENT_GPS_DATA,
|
|
||||||
EVENT_TRACK_POINTS_DATA,
|
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
import {
|
import { querySearchThings } from "@/controller/DeviceController";
|
||||||
queryAlarm,
|
|
||||||
queryEntities,
|
|
||||||
queryGpsData,
|
|
||||||
queryTrackPoints,
|
|
||||||
} from "@/controller/DeviceController";
|
|
||||||
import { queryBanzones } from "@/controller/MapController";
|
import { queryBanzones } from "@/controller/MapController";
|
||||||
import eventBus from "@/utils/eventBus";
|
import eventBus from "@/utils/eventBus";
|
||||||
|
|
||||||
const intervals: {
|
const intervals: {
|
||||||
gps: ReturnType<typeof setInterval> | null;
|
|
||||||
alarm: ReturnType<typeof setInterval> | null;
|
|
||||||
entities: ReturnType<typeof setInterval> | null;
|
|
||||||
trackPoints: ReturnType<typeof setInterval> | null;
|
|
||||||
banzones: ReturnType<typeof setInterval> | null;
|
banzones: ReturnType<typeof setInterval> | null;
|
||||||
|
searchThings: ReturnType<typeof setInterval> | null;
|
||||||
} = {
|
} = {
|
||||||
gps: null,
|
|
||||||
alarm: null,
|
|
||||||
entities: null,
|
|
||||||
trackPoints: null,
|
|
||||||
banzones: null,
|
banzones: null,
|
||||||
|
searchThings: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getGpsEventBus() {
|
|
||||||
if (intervals.gps) return;
|
|
||||||
// console.log("Starting GPS poller");
|
|
||||||
|
|
||||||
const getGpsData = async () => {
|
|
||||||
try {
|
|
||||||
// console.log("GPS: fetching data...");
|
|
||||||
const resp = await queryGpsData();
|
|
||||||
if (resp && resp.data) {
|
|
||||||
// console.log("GPS: emitting data", resp.data);
|
|
||||||
eventBus.emit(EVENT_GPS_DATA, resp.data);
|
|
||||||
} else {
|
|
||||||
console.log("GPS: no data returned");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("GPS: fetch error", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run immediately once, then schedule
|
|
||||||
getGpsData();
|
|
||||||
intervals.gps = setInterval(() => {
|
|
||||||
getGpsData();
|
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAlarmEventBus() {
|
|
||||||
if (intervals.alarm) return;
|
|
||||||
// console.log("Goi ham get Alarm");
|
|
||||||
const getAlarmData = async () => {
|
|
||||||
try {
|
|
||||||
// console.log("Alarm: fetching data...");
|
|
||||||
const resp = await queryAlarm();
|
|
||||||
if (resp && resp.data) {
|
|
||||||
// console.log(
|
|
||||||
// "Alarm: emitting data",
|
|
||||||
// resp.data?.alarms?.length ?? resp.data
|
|
||||||
// );
|
|
||||||
eventBus.emit(EVENT_ALARM_DATA, resp.data);
|
|
||||||
} else {
|
|
||||||
console.log("Alarm: no data returned");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Alarm: fetch error", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getAlarmData();
|
|
||||||
intervals.alarm = setInterval(() => {
|
|
||||||
getAlarmData();
|
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEntitiesEventBus() {
|
|
||||||
if (intervals.entities) return;
|
|
||||||
// console.log("Goi ham get Entities");
|
|
||||||
const getEntitiesData = async () => {
|
|
||||||
try {
|
|
||||||
// console.log("Entities: fetching data...");
|
|
||||||
const resp = await queryEntities();
|
|
||||||
if (resp && resp.length > 0) {
|
|
||||||
// console.log("Entities: emitting", resp.length);
|
|
||||||
eventBus.emit(EVENT_ENTITY_DATA, resp);
|
|
||||||
} else {
|
|
||||||
console.log("Entities: no data returned");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Entities: fetch error", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getEntitiesData();
|
|
||||||
intervals.entities = setInterval(() => {
|
|
||||||
getEntitiesData();
|
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrackPointsEventBus() {
|
|
||||||
if (intervals.trackPoints) return;
|
|
||||||
// console.log("Goi ham get Track Points");
|
|
||||||
const getTrackPointsData = async () => {
|
|
||||||
try {
|
|
||||||
// console.log("TrackPoints: fetching data...");
|
|
||||||
const resp = await queryTrackPoints();
|
|
||||||
if (resp && resp.data && resp.data.length > 0) {
|
|
||||||
// console.log("TrackPoints: emitting", resp.data.length);
|
|
||||||
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
|
|
||||||
} else {
|
|
||||||
console.log("TrackPoints: no data returned");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("TrackPoints: fetch error", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getTrackPointsData();
|
|
||||||
intervals.trackPoints = setInterval(() => {
|
|
||||||
getTrackPointsData();
|
|
||||||
}, AUTO_REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBanzonesEventBus() {
|
export function getBanzonesEventBus() {
|
||||||
if (intervals.banzones) return;
|
if (intervals.banzones) return;
|
||||||
const getBanzonesData = async () => {
|
const getBanzonesData = async () => {
|
||||||
@@ -153,6 +38,37 @@ export function getBanzonesEventBus() {
|
|||||||
}, AUTO_REFRESH_INTERVAL * 60);
|
}, AUTO_REFRESH_INTERVAL * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchThingEventBus(body: Model.SearchThingBody) {
|
||||||
|
// Clear interval cũ nếu có
|
||||||
|
if (intervals.searchThings) {
|
||||||
|
clearInterval(intervals.searchThings);
|
||||||
|
intervals.searchThings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchThingsData = async () => {
|
||||||
|
// console.log("call api with body:", body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await querySearchThings(body);
|
||||||
|
if (resp && resp.data) {
|
||||||
|
eventBus.emit(EVENT_SEARCH_THINGS, resp.data);
|
||||||
|
} else {
|
||||||
|
console.log("SearchThings: no data returned");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SearchThings: fetch error", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gọi ngay lần đầu
|
||||||
|
searchThingsData();
|
||||||
|
|
||||||
|
// Sau đó setup interval để gọi lại mỗi 30s
|
||||||
|
intervals.searchThings = setInterval(() => {
|
||||||
|
searchThingsData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL * 6); // 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
export function stopEvents() {
|
export function stopEvents() {
|
||||||
Object.keys(intervals).forEach((k) => {
|
Object.keys(intervals).forEach((k) => {
|
||||||
const key = k as keyof typeof intervals;
|
const key = k as keyof typeof intervals;
|
||||||
@@ -164,9 +80,5 @@ export function stopEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startEvents() {
|
export function startEvents() {
|
||||||
getGpsEventBus();
|
|
||||||
getAlarmEventBus();
|
|
||||||
getEntitiesEventBus();
|
|
||||||
getTrackPointsEventBus();
|
|
||||||
getBanzonesEventBus();
|
getBanzonesEventBus();
|
||||||
}
|
}
|
||||||
|
|||||||
48
services/time_service.tsx
Normal file
48
services/time_service.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/vi";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale("vi");
|
||||||
|
|
||||||
|
export { dayjs };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chuyển đổi unix timestamp thành text thời gian tương đối
|
||||||
|
* @param unixTime - Unix timestamp (seconds hoặc milliseconds)
|
||||||
|
* @returns String mô tả thời gian tương đối (vd: "5 phút trước", "2 giờ trước")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(unixTime: number): string {
|
||||||
|
if (!unixTime || unixTime <= 0) return "Không rõ";
|
||||||
|
|
||||||
|
// Xác định đơn vị timestamp (seconds hoặc milliseconds)
|
||||||
|
const timestamp = unixTime < 10000000000 ? unixTime * 1000 : unixTime;
|
||||||
|
|
||||||
|
const updateDate = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - updateDate.getTime();
|
||||||
|
|
||||||
|
// Nếu thời gian trong tương lai
|
||||||
|
if (diffMs < 0) return "Vừa xong";
|
||||||
|
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMins = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7);
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
const diffYears = Math.floor(diffDays / 365);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return "Vừa xong";
|
||||||
|
if (diffMins < 60) return `${diffMins} phút trước`;
|
||||||
|
if (diffHours < 24) return `${diffHours} giờ trước`;
|
||||||
|
if (diffDays < 7) return `${diffDays} ngày trước`;
|
||||||
|
if (diffWeeks < 4) return `${diffWeeks} tuần trước`;
|
||||||
|
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");
|
||||||
|
};
|
||||||
24
state/use-ship-types.ts
Normal file
24
state/use-ship-types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { queryShipTypes } from "@/controller/DeviceController";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type ShipType = {
|
||||||
|
shipTypes: Model.ShipType[] | [];
|
||||||
|
getShipTypes: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useShipTypes = create<ShipType>((set) => ({
|
||||||
|
shipTypes: [],
|
||||||
|
getShipTypes: async () => {
|
||||||
|
try {
|
||||||
|
const response = await queryShipTypes();
|
||||||
|
set({ shipTypes: response.data, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when fetch shipTypes: ", error);
|
||||||
|
set({ error: "Failed to fetch shipTypes data", loading: false });
|
||||||
|
set({ shipTypes: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
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