Compare commits

..

11 Commits

Author SHA1 Message Date
Tran Anh Tuan
12fb7c48ed sửa lỗi form không reset, cập nhật thêm hàm gọi api khi thêm mới tàu 2025-12-10 20:05:34 +07:00
Tran Anh Tuan
35027a7e23 sửa lỗi image 2025-12-10 19:54:16 +07:00
Tran Anh Tuan
6af6749712 xóa package expo-image 2025-12-10 19:52:13 +07:00
Tran Anh Tuan
3e1c4dcbc5 thêm giao diện quản lý thuyền 2025-12-10 19:49:54 +07:00
Tran Anh Tuan
df4318fed4 thêm giao diện cảnh báo 2025-12-09 11:37:19 +07:00
7c3497d159 cập nhật phần modal thêm chuyến đi mới 2025-12-08 23:41:05 +07:00
0e1332f433 cập nhật icon trong filter 2025-12-08 10:58:12 +07:00
347bd1a7c1 Gip khi cuộn xuống ( tab diary ) 2025-12-08 10:05:04 +07:00
Tran Anh Tuan
695066a5e7 Hiển thị tọa độ và khu vực khi tàu vi phạm 2025-12-08 09:31:28 +07:00
c47d9ad14c cập nhật themes cho tab 2025-12-07 23:09:51 +07:00
e405a0bcfa update tab nhật ký (Lọc, Ngôn ngữ,...) 2025-12-07 20:23:10 +07:00
66 changed files with 9636 additions and 1673 deletions

1494
ReactQuery_Axios.md Normal file

File diff suppressed because it is too large Load Diff

277
add_trip_modal_summary.md Normal file
View 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
![Add Trip Form Reference](/Users/nguyennhatminh/.gemini/antigravity/brain/8d790068-d5bf-410f-a259-de9d4bdb1e20/uploaded_image_1765167755463.png)
## 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!

View File

@@ -3,8 +3,10 @@ import { Tabs, useSegments } from "expo-router";
import { HapticTab } from "@/components/haptic-tab"; import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { queryProfile } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
import { useColorScheme } from "@/hooks/use-theme-context"; import { useColorScheme } from "@/hooks/use-theme-context";
import { addUserStorage } from "@/utils/storage";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
export default function TabLayout() { export default function TabLayout() {
@@ -12,7 +14,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 });
@@ -29,6 +31,23 @@ export default function TabLayout() {
} }
}, [currentSegment]); }, [currentSegment]);
useEffect(() => {
const getUserProfile = async () => {
try {
const resp = await queryProfile();
if (resp.data && resp.status === 200) {
await addUserStorage(
resp.data.id || "",
resp.data.metadata?.user_type || ""
);
}
} catch (error) {
console.error("Error when get Profile: ", error);
}
};
getUserProfile();
}, []);
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -60,7 +79,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.fill" color={color} /> <IconSymbol
size={28}
name="square.stack.3d.up.fill"
color={color}
/>
), ),
}} }}
/> />

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ActivityIndicator,
FlatList,
Platform, Platform,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
@@ -9,22 +10,35 @@ import {
} from "react-native"; } 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 { 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
}); });
// Body things (đang fix cứng)
// State for pagination
const [allTrips, setAllTrips] = useState<any[]>([]);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const isInitialLoad = useRef(true);
// Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = { const payloadThings: Model.SearchThingBody = {
offset: 0, offset: 0,
limit: 200, limit: 200,
@@ -34,56 +48,74 @@ export default function diary() {
not_empty: "ship_name, ship_reg_number", not_empty: "ship_name, ship_reg_number",
}, },
}; };
// Gọi API things // Gọi API things
const { things, getThings } = useThings(); const { getThings } = useThings();
useEffect(() => { useEffect(() => {
getThings(payloadThings); getThings(payloadThings);
}, []); }, []);
console.log(things); // State cho payload trips
const [payloadTrips, setPayloadTrips] = useState<Model.TripListBody>({
// Filter trips based on search text and filters name: "",
const filteredTrips = MOCK_TRIPS.filter((trip) => { order: "",
// Search filter dir: "desc",
if (searchText) { offset: 0,
const searchLower = searchText.toLowerCase(); limit: 10,
const matchesSearch = metadata: {
trip.title.toLowerCase().includes(searchLower) || from: "",
trip.code.toLowerCase().includes(searchLower) || to: "",
trip.vessel.toLowerCase().includes(searchLower) || ship_name: "",
trip.vesselCode.toLowerCase().includes(searchLower); reg_number: "",
province_code: "",
if (!matchesSearch) return false; owner_id: "",
} ship_id: "",
status: "",
// Status filter },
if (filters.status && trip.status !== filters.status) {
return false;
}
// Date range filter
if (filters.startDate || filters.endDate) {
const tripDate = new Date(trip.departureDate);
if (filters.startDate && tripDate < filters.startDate) {
return false;
}
if (filters.endDate) {
const endOfDay = new Date(filters.endDate);
endOfDay.setHours(23, 59, 59, 999);
if (tripDate > endOfDay) {
return false;
}
}
}
return true;
}); });
const handleSearch = (text: string) => { const { tripsList, getTripsList, loading } = useTripsList();
setSearchText(text);
}; // 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);
@@ -91,8 +123,51 @@ export default function diary() {
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);
@@ -123,60 +198,126 @@ export default function diary() {
// TODO: Show confirmation dialog and delete trip // 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 ( return (
<SafeAreaView style={styles.safeArea}> <View style={styles.loadingFooter}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.loadingMore")}
</Text>
</View>
);
};
// Empty component
const renderEmpty = () => {
// Hiển thị loading khi đang load (lần đầu hoặc load more) và chưa có dữ liệu
if (loading && allTrips.length === 0) {
return (
<View style={styles.emptyState}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// Chỉ hiển thị "không có dữ liệu" khi đã load xong và thực sự không có trips
return (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noTripsFound")}
</Text>
</View>
);
};
return (
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<Text style={styles.titleText}>Nhật 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}
onView={() => handleViewTrip(trip.id)} windowSize={10}
onEdit={() => handleEditTrip(trip.id)} initialNumToRender={10}
onTeam={() => handleViewTeam(trip.id)}
onSend={() => handleSendTrip(trip.id)}
onDelete={() => handleDeleteTrip(trip.id)}
/> />
))}
{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 */}
@@ -185,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>
); );
} }
@@ -192,7 +339,6 @@ 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,
@@ -203,13 +349,19 @@ const styles = StyleSheet.create({
fontWeight: "700", fontWeight: "700",
lineHeight: 36, lineHeight: 36,
marginBottom: 10, 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",
@@ -220,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,
@@ -246,9 +397,6 @@ const styles = StyleSheet.create({
default: "System", default: "System",
}), }),
}, },
scrollView: {
flex: 1,
},
scrollContent: { scrollContent: {
paddingBottom: 20, paddingBottom: 20,
}, },
@@ -259,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",

View File

@@ -1,46 +1,69 @@
import DraggablePanel from "@/components/DraggablePanel"; import DraggablePanel from "@/components/DraggablePanel";
import IconButton from "@/components/IconButton"; import IconButton from "@/components/IconButton";
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel"; import AlarmList from "@/components/map/AlarmList";
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
import ShipInfo from "@/components/map/ShipInfo"; import ShipInfo from "@/components/map/ShipInfo";
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState"; import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
import ZoneInMap from "@/components/map/ZoneInMap";
import ShipSearchForm, { import ShipSearchForm, {
SearchShipResponse, SearchShipResponse,
} from "@/components/ShipSearchForm"; } from "@/components/ShipSearchForm";
import { ThemedText } from "@/components/themed-text";
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants"; import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
import { queryBanzoneById } from "@/controller/MapController";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import { useThemeContext } from "@/hooks/use-theme-context"; import { useThemeContext } from "@/hooks/use-theme-context";
import { searchThingEventBus } from "@/services/device_events"; import { searchThingEventBus } from "@/services/device_events";
import { getShipIcon } from "@/services/map_service"; import { getShipIcon } from "@/services/map_service";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
import { AntDesign } from "@expo/vector-icons"; import { AntDesign, Ionicons } from "@expo/vector-icons";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
Animated, Animated,
Dimensions,
Image, Image,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import MapView, { Marker } from "react-native-maps"; import MapView, { Marker } from "react-native-maps";
interface ZoneDataParsed {
zone_type?: number;
zone_name?: string;
zone_id?: string;
message?: string;
alarm_type?: number;
lat?: number;
lon?: number;
s?: number;
h?: number;
fishing?: boolean;
gps_time?: number;
}
export interface AlarmData {
thing_id: string;
ship_name?: string;
zone: ZoneDataParsed;
type: "approaching" | "entered" | "fishing";
}
export interface BanzoneWithAlarm {
alarms: AlarmData;
zone?: Model.Zone;
}
export default function HomeScreen() { export default function HomeScreen() {
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null); const mapRef = useRef<MapView>(null);
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null); const [alarms, setAlarms] = useState<AlarmData[]>([]);
const [trackPointsData, setTrackPointsData] = useState< const [banzoneWithAlarm, setBanzoneWithAlarm] =
Model.ShipTrackPoint[] | null useState<BanzoneWithAlarm | null>(null);
>(null); const [allBanZones, setAllBanZones] = useState<BanzoneWithAlarm[]>([]);
const [circleRadius, setCircleRadius] = useState(100); const [showAllAlarmsOnMap, setShowAllAlarmsOnMap] = useState(false);
const [zoomLevel, setZoomLevel] = useState(10);
const [isFirstLoad, setIsFirstLoad] = useState(true); const [isFirstLoad, setIsFirstLoad] = useState(true);
const [polylineCoordinates, setPolylineCoordinates] = useState<
PolylineWithLabelProps[]
>([]);
const [polygonCoordinates, setPolygonCoordinates] = useState<
PolygonWithLabelProps[]
>([]);
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false); const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
const [isPanelExpanded, setIsPanelExpanded] = useState(false); const [isPanelExpanded, setIsPanelExpanded] = useState(false);
const [things, setThings] = useState<Model.ThingsResponse | null>(null); const [things, setThings] = useState<Model.ThingsResponse | null>(null);
@@ -54,15 +77,36 @@ export default function HomeScreen() {
const [tagStatePayload, setTagStatePayload] = const [tagStatePayload, setTagStatePayload] =
useState<TagStateCallbackPayload | null>(null); useState<TagStateCallbackPayload | null>(null);
const [isShowAlarmList, setIsShowAlarmList] = useState(false);
// Control mount so we can animate close before unmounting
const [isAlarmListMounted, setIsAlarmListMounted] = useState(false);
// Thêm state để quản lý tracksViewChanges // Thêm state để quản lý tracksViewChanges
const [tracksViewChanges, setTracksViewChanges] = useState(true); const [tracksViewChanges, setTracksViewChanges] = useState(true);
// Alarm list animation
const screenHeight = Dimensions.get("window").height;
const alarmListHeight = Math.round(screenHeight * 0.3);
const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current;
const alarmOpacity = useRef(new Animated.Value(0)).current;
const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden
useEffect(() => { useEffect(() => {
if (tagStatePayload) { if (tagStatePayload) {
searchThings(); searchThings();
} }
}, [tagStatePayload]); }, [tagStatePayload]);
const openAlarmList = () => {
setIsAlarmListMounted(true);
setIsShowAlarmList(true);
};
const closeAlarmList = () => {
// Trigger the animation; the effect will unmount when complete
setIsShowAlarmList(false);
setBanzoneWithAlarm(null);
};
useEffect(() => { useEffect(() => {
if (shipSearchFormData) { if (shipSearchFormData) {
searchThings(); searchThings();
@@ -93,7 +137,7 @@ export default function HomeScreen() {
...thingsData, ...thingsData,
things: sortedThings, things: sortedThings,
}; };
console.log("Things Updated: ", sortedThingsResponse.things?.length); // console.log("Things Updated: ", sortedThingsResponse.things?.length);
setThings(sortedThingsResponse); setThings(sortedThingsResponse);
}; };
@@ -105,101 +149,85 @@ export default function HomeScreen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (things) { if (things?.things) {
// console.log("Things Updated: ", things.things?.length); const alarmTypes = [
// const gpsDatas: Model.GPSResponse[] = []; {
// for (const thing of things.things || []) { key: "zone_approaching_alarm_list",
// if (thing.metadata?.gps) { type: "approaching" as const,
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps); },
// gpsDatas.push(gps); { key: "zone_entered_alarm_list", type: "entered" as const },
// } { key: "zone_fishing_alarm_list", type: "fishing" as const },
// } ];
// console.log("GPS Lenght: ", gpsDatas.length);
// setGpsData(gpsDatas); const newAlarms: AlarmData[] = [];
for (const thing of things.things) {
for (const { key, type } of alarmTypes) {
if ((thing.metadata as any)?.[key] != "[]") {
const zoneList: ZoneDataParsed[] = JSON.parse(
(thing?.metadata as any)?.[key] || "[]"
);
for (const zone of zoneList) {
const alarmData: AlarmData = {
thing_id: thing.id || "",
ship_name: thing.metadata?.ship_name,
zone: zone,
type: type,
};
newAlarms.push(alarmData);
}
}
}
}
// Update alarms, removing old ones not in newAlarms
setAlarms((prev) => {
const toKeep = prev.filter((a) =>
newAlarms.some(
(na) =>
na.thing_id === a.thing_id && na.zone.zone_id === a.zone.zone_id
)
);
const toAdd = newAlarms.filter(
(na) =>
!prev.some(
(a) =>
a.thing_id === na.thing_id && a.zone.zone_id === a.zone.zone_id
)
);
return [...toKeep, ...toAdd];
});
} }
}, [things]); }, [things]);
// useEffect(() => { useEffect(() => {
// setPolylineCoordinates([]); const approaching = alarms.filter((a) => a.type === "approaching");
// setPolygonCoordinates([]); const entered = alarms.filter((a) => a.type === "entered");
// if (!entityData) return; const fishing = alarms.filter((a) => a.type === "fishing");
// if (!banzoneData) return;
// for (const entity of entityData) {
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
// continue;
// }
// let zones: any[] = []; if (approaching.length > 0) {
// try { console.log("ZoneApproachingAlarm: ", approaching);
// zones = entity.valueString ? JSON.parse(entity.valueString) : []; } else {
// } catch (parseError) { // console.log("No ZoneApproachingAlarm");
// console.error("Error parsing zone list:", parseError); }
// continue; if (entered.length > 0) {
// } console.log("ZoneEnteredAlarm: ", entered);
// // Nếu danh sách zone rỗng, clear tất cả } else {
// if (zones.length === 0) { // console.log("No ZoneEnteredAlarm");
// setPolylineCoordinates([]); }
// setPolygonCoordinates([]); if (fishing.length > 0) {
// return; console.log("ZoneFishingAlarm: ", fishing);
// } } else {
// console.log("No ZoneFishingAlarm");
}
}, [alarms]);
// let polylines: PolylineWithLabelProps[] = []; // Load all banzones when alarms change (for showing all alarms on map)
// let polygons: PolygonWithLabelProps[] = []; useEffect(() => {
if (alarms.length > 0 && showAllAlarmsOnMap) {
// for (const zone of zones) { loadAllBanZones();
// // console.log("Zone Data: ", zone); }
// const geom = banzoneData.find((b) => b.id === zone.zone_id); }, [alarms, showAllAlarmsOnMap]);
// if (!geom) {
// continue;
// }
// const { geom_type, geom_lines, geom_poly } = geom.geom || {};
// if (typeof geom_type !== "number") {
// continue;
// }
// if (geom_type === 2) {
// // if(oldEntityData.find(e => e.id === ))
// // foundPolyline = true;
// const coordinates = convertWKTLineStringToLatLngArray(
// geom_lines || ""
// );
// if (coordinates.length > 0) {
// polylines.push({
// coordinates: coordinates.map((coord) => ({
// latitude: coord[0],
// longitude: coord[1],
// })),
// label: zone?.zone_name ?? "",
// content: zone?.message ?? "",
// });
// } else {
// console.log("Không tìm thấy polyline trong alarm");
// }
// } else if (geom_type === 1) {
// // foundPolygon = true;
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
// if (coordinates.length > 0) {
// // console.log("Polygon Coordinate: ", coordinates);
// const zonePolygons = coordinates.map((polygon) => ({
// coordinates: polygon.map((coord) => ({
// latitude: coord[0],
// longitude: coord[1],
// })),
// label: zone?.zone_name ?? "",
// content: zone?.message ?? "",
// }));
// polygons.push(...zonePolygons);
// } else {
// console.log("Không tìm thấy polygon trong alarm");
// }
// }
// }
// setPolylineCoordinates(polylines);
// setPolygonCoordinates(polygons);
// }
// }, [banzoneData, entityData]);
// Hàm tính radius cố định khi zoom change
const calculateRadiusFromZoom = (zoom: number) => { const calculateRadiusFromZoom = (zoom: number) => {
const baseZoom = 10; const baseZoom = 10;
@@ -217,8 +245,8 @@ export default function HomeScreen() {
// zoom = log2(360 / (latitudeDelta * 2)) + 8 // zoom = log2(360 / (latitudeDelta * 2)) + 8
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8); const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
const newRadius = calculateRadiusFromZoom(zoom); const newRadius = calculateRadiusFromZoom(zoom);
setCircleRadius(newRadius); // setCircleRadius(newRadius);
setZoomLevel(zoom); // setZoomLevel(zoom);
// console.log("Zoom level:", zoom, "Circle radius:", newRadius); // console.log("Zoom level:", zoom, "Circle radius:", newRadius);
}; };
@@ -303,8 +331,53 @@ export default function HomeScreen() {
} }
}, [isFirstLoad]); }, [isFirstLoad]);
// Animate alarm panel when isShowAlarmList changes
// Keep the overlay mounted while animating it in/out.
useEffect(() => {
if (isShowAlarmList) {
// Ensure mounted then animate to visible
setIsAlarmListMounted(true);
Animated.parallel([
Animated.timing(alarmTranslateY, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(alarmOpacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
} else {
// Animate out, then unmount
Animated.parallel([
Animated.timing(alarmTranslateY, {
toValue: alarmListHeight,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(alarmOpacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => {
setIsAlarmListMounted(false);
});
}
}, [alarmTranslateY, alarmListHeight, alarmOpacity, isShowAlarmList]);
useEffect(() => {
Animated.timing(uiAnim, {
toValue: isAlarmListMounted ? 0 : 1,
duration: 200,
useNativeDriver: true,
}).start();
}, [uiAnim, isAlarmListMounted]);
const searchThings = async () => { const searchThings = async () => {
console.log("FormSearch Playload in Search Thing: ", shipSearchFormData); // console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
// Xây dựng query state dựa trên logic bạn cung cấp // Xây dựng query state dựa trên logic bạn cung cấp
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : ""; const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
@@ -391,7 +464,7 @@ export default function HomeScreen() {
not_empty: "ship_id", not_empty: "ship_id",
}, },
}; };
console.log("Search Params: ", searchParams); // console.log("Search Params: ", searchParams);
// Gọi API tìm kiếm // Gọi API tìm kiếm
searchThingEventBus(searchParams); searchThingEventBus(searchParams);
@@ -401,6 +474,50 @@ export default function HomeScreen() {
setShipSearchFormOpen(false); setShipSearchFormOpen(false);
}; };
const loadAllBanZones = async () => {
try {
const banzonePromises = alarms.map(async (alarm) => {
try {
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
return {
alarms: alarm,
zone: banzone.data,
};
} catch (error) {
console.warn(`Cannot get banzone for zone_id: ${alarm.zone.zone_id}`);
return null;
}
});
const banzoneResults = await Promise.all(banzonePromises);
const validBanZones = banzoneResults.filter(
(banzone): banzone is NonNullable<typeof banzone> => banzone !== null
);
setAllBanZones(validBanZones);
} catch (error) {
console.error("Error loading all banzones:", error);
}
};
const handleAlarmPress = async (alarm: AlarmData) => {
console.log("Alarm pressed from list:", alarm);
try {
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
// console.log("Banzone API response:", banzone);
// console.log("Banzone data:", banzone.data);
// console.log("Zone geometry:", banzone.data?.geometry);
const banzoneWithAlarm: BanzoneWithAlarm = {
alarms: alarm,
zone: banzone.data,
};
setBanzoneWithAlarm(banzoneWithAlarm);
setShowAllAlarmsOnMap(false);
} catch (error) {
console.error("Cannot get Banzone:", error);
}
};
const hasActiveFilters = shipSearchFormData const hasActiveFilters = shipSearchFormData
? shipSearchFormData.ship_name !== "" || ? shipSearchFormData.ship_name !== "" ||
shipSearchFormData.reg_number !== "" || shipSearchFormData.reg_number !== "" ||
@@ -418,6 +535,7 @@ export default function HomeScreen() {
<GestureHandlerRootView style={styles.container}> <GestureHandlerRootView style={styles.container}>
<View style={styles.container}> <View style={styles.container}>
<MapView <MapView
ref={mapRef}
onMapReady={handleMapReady} onMapReady={handleMapReady}
onRegionChangeComplete={handleRegionChangeComplete} onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map} style={styles.map}
@@ -428,8 +546,9 @@ export default function HomeScreen() {
loadingEnabled={true} loadingEnabled={true}
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"} mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
rotateEnabled={false} rotateEnabled={false}
// onMarkerPress={onMarkerPress}
> >
{things?.things && things.things.length > 0 && ( {!banzoneWithAlarm && things?.things && things.things.length > 0 && (
<> <>
{things.things {things.things
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check .filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
@@ -454,6 +573,10 @@ export default function HomeScreen() {
}} }}
zIndex={50} zIndex={50}
anchor={{ x: 0.5, y: 0.5 }} anchor={{ x: 0.5, y: 0.5 }}
title={thing.metadata?.ship_name}
description={`Trạng thái: ${
gpsData.fishing ? "Đang đánh bắt" : "Không đánh bắt"
}`}
// Chỉ tracks changes khi cần thiết // Chỉ tracks changes khi cần thiết
tracksViewChanges={ tracksViewChanges={
platform === IOS_PLATFORM ? tracksViewChanges : true platform === IOS_PLATFORM ? tracksViewChanges : true
@@ -461,6 +584,7 @@ export default function HomeScreen() {
// Thêm identifier để iOS optimize // Thêm identifier để iOS optimize
identifier={uniqueKey} identifier={uniqueKey}
> >
{/* <Callout tooltip></Callout> */}
<View className="w-8 h-8 items-center justify-center"> <View className="w-8 h-8 items-center justify-center">
<View style={styles.pingContainer}> <View style={styles.pingContainer}>
{thing.metadata?.state_level === 3 && ( {thing.metadata?.state_level === 3 && (
@@ -507,9 +631,16 @@ export default function HomeScreen() {
})} })}
</> </>
)} )}
{(banzoneWithAlarm ||
(showAllAlarmsOnMap && allBanZones.length > 0)) && (
<ZoneInMap
banzones={banzoneWithAlarm ? [banzoneWithAlarm] : allBanZones}
mapRef={mapRef}
/>
)}
</MapView> </MapView>
<View className="absolute top-20 left-5"> <View className="absolute top-12 left-5">
{!isPanelExpanded && ( {!isAlarmListMounted && !isPanelExpanded && (
<IconButton <IconButton
icon={<AntDesign name="filter" size={16} />} icon={<AntDesign name="filter" size={16} />}
type="primary" type="primary"
@@ -528,12 +659,13 @@ export default function HomeScreen() {
<GPSInfoPanel gpsData={gpsData!} /> */} <GPSInfoPanel gpsData={gpsData!} /> */}
{/* Draggable Panel */} {/* Draggable Panel */}
{!isAlarmListMounted && (
<DraggablePanel <DraggablePanel
minHeightPct={0.1} minHeightPct={0.1}
maxHeightPct={0.6} maxHeightPct={0.6}
initialState="min" initialState="min"
onExpandedChange={(expanded) => { onExpandedChange={(expanded) => {
console.log("Panel expanded:", expanded); // console.log("Panel expanded:", expanded);
setIsPanelExpanded(expanded); setIsPanelExpanded(expanded);
}} }}
> >
@@ -618,12 +750,67 @@ export default function HomeScreen() {
</View> </View>
</> </>
</DraggablePanel> </DraggablePanel>
)}
{/* Alarm list overlay */}
{isAlarmListMounted && (
<Animated.View
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: alarmListHeight,
transform: [{ translateY: alarmTranslateY }],
opacity: alarmOpacity,
elevation: 10,
zIndex: 10,
}}
>
<View className="bg-white rounded-t-3xl shadow-md overflow-hidden h-full z-50">
<View className="flex-row items-center justify-between px-4 py-2">
<Text className="text-lg font-semibold">
Danh sách cảnh báo
</Text>
<TouchableOpacity
onPress={() => closeAlarmList()}
style={{ padding: 6 }}
>
<Ionicons name="close" size={20} color="#374151" />
</TouchableOpacity>
</View>
<View
style={{
flex: 1,
backgroundColor: "#F9FAFB",
zIndex: 50,
}}
>
{/* <ThemedText className="text-lg font-semibold">Body</ThemedText> */}
<AlarmList data={alarms} onPress={handleAlarmPress} />
</View>
</View>
</Animated.View>
)}
<ShipSearchForm <ShipSearchForm
initialValues={shipSearchFormData} initialValues={shipSearchFormData}
isOpen={shipSearchFormOpen} isOpen={shipSearchFormOpen}
onClose={() => setShipSearchFormOpen(false)} onClose={() => setShipSearchFormOpen(false)}
onSubmit={handleOnSubmitSearchForm} onSubmit={handleOnSubmitSearchForm}
/> />
{!isAlarmListMounted && alarms.length > 0 && (
<View className="absolute top-12 right-5 space-y-2">
<IconButton
icon={<Ionicons name="warning" size={16} color="#fff" />}
type="danger"
size="middle"
onPress={() => openAlarmList()}
>
<ThemedText className="text-sm font-semibold">
{alarms.length}
</ThemedText>
</IconButton>
</View>
)}
</View> </View>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@@ -1,35 +1,178 @@
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; import DevicesScreen from "@/components/manager/devices";
import FleetsScreen from "@/components/manager/fleets";
import ShipsScreen from "@/components/manager/ships";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { Colors } from "@/config";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useEffect, useMemo, useRef, useState } from "react";
import { Animated, 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 manager() { export default function manager() {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const [selected, setSelected] = useState<"ships" | "devices" | "fleets">(
"ships"
);
const [containerWidth, setContainerWidth] = useState(0);
const indicatorTranslate = useRef(new Animated.Value(0)).current;
const SEGMENT_COUNT = 3;
const indexMap: Record<string, number> = {
ships: 0,
devices: 1,
fleets: 2,
};
const SegmentButton = ({
label,
active,
onPress,
}: {
label: string;
active?: boolean;
onPress?: () => void;
}) => {
return ( return (
<SafeAreaView style={{ flex: 1 }}> <TouchableOpacity
<ScrollView contentContainerStyle={styles.scrollContent}> style={[styles.segmentButton, active && styles.segmentButtonActive]}
onPress={onPress}
activeOpacity={0.8}
>
<ThemedText
style={[styles.segmentText, active && styles.segmentTextActive]}
>
{label}
</ThemedText>
</TouchableOpacity>
);
};
useEffect(() => {
if (containerWidth <= 0) return;
const segmentWidth = containerWidth / SEGMENT_COUNT;
const toValue = indexMap[selected] * segmentWidth;
Animated.spring(indicatorTranslate, {
toValue,
useNativeDriver: true,
friction: 14,
tension: 100,
}).start();
}, [selected, containerWidth, indicatorTranslate]);
return (
<SafeAreaView style={{ flex: 1 }} edges={["top"]}>
<ThemedView style={styles.scrollContent}>
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.titleText}>Quản tàu </Text> <ThemedView style={styles.header}>
<View
style={styles.segmentContainer}
onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)}
>
{/* sliding indicator */}
{containerWidth > 0 && (
<Animated.View
style={[
styles.indicator,
{
width: Math.max(containerWidth / SEGMENT_COUNT - 8, 0),
transform: [{ translateX: indicatorTranslate }],
},
]}
/>
)}
<SegmentButton
label="Tàu"
active={selected === "ships"}
onPress={() => setSelected("ships")}
/>
<SegmentButton
label="Thiết bị"
active={selected === "devices"}
onPress={() => setSelected("devices")}
/>
<SegmentButton
label="Đội tàu"
active={selected === "fleets"}
onPress={() => setSelected("fleets")}
/>
</View> </View>
</ScrollView> </ThemedView>
<View style={styles.contentWrapper}>
{selected === "ships" && <ShipsScreen />}
{selected === "devices" && <DevicesScreen />}
{selected === "fleets" && <FleetsScreen />}
</View>
</View>
</ThemedView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
}, },
container: { container: {
alignItems: "center", alignItems: "center",
padding: 15, flex: 1,
}, },
titleText: {
fontSize: 32, header: {
fontWeight: "700", width: "100%",
lineHeight: 40, paddingVertical: 8,
marginBottom: 30, paddingHorizontal: 4,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
}, },
}); segmentContainer: {
flexDirection: "row",
backgroundColor: colors.backgroundSecondary,
borderRadius: 10,
padding: 4,
alignSelf: "stretch",
},
segmentButton: {
flex: 1,
paddingVertical: 8,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
},
segmentButtonActive: {
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
shadowColor: scheme === "dark" ? "transparent" : "#000",
shadowOpacity: scheme === "dark" ? 0 : 0.1,
shadowRadius: 4,
elevation: scheme === "dark" ? 0 : 2,
},
segmentText: {
fontSize: 14,
fontWeight: "600",
color: colors.textSecondary,
},
segmentTextActive: {
color: colors.text,
},
indicator: {
position: "absolute",
left: 4,
top: 4,
bottom: 4,
backgroundColor: scheme === "dark" ? "#435B66" : colors.surface,
borderRadius: 8,
shadowColor: scheme === "dark" ? "transparent" : "#000",
shadowOpacity: scheme === "dark" ? 0 : 0.1,
shadowRadius: 4,
elevation: scheme === "dark" ? 0 : 2,
},
contentWrapper: {
flex: 1,
alignSelf: "stretch",
width: "100%",
},
});

View File

@@ -1,83 +0,0 @@
// import ButtonCancelTrip from "@/components/ButtonCancelTrip";
// import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
// import ButtonEndTrip from "@/components/ButtonEndTrip";
// import CrewListTable from "@/components/tripInfo/CrewListTable";
// import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
// import NetListTable from "@/components/tripInfo/NetListTable";
// import TripCostTable from "@/components/tripInfo/TripCostTable";
// import { useI18n } from "@/hooks/use-i18n";
// import { useThemeContext } from "@/hooks/use-theme-context";
// import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
// import { SafeAreaView } from "react-native-safe-area-context";
// export default function TripInfoScreen() {
// const { t } = useI18n();
// const { colors } = useThemeContext();
// return (
// <SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
// <View style={styles.header}>
// <Text style={[styles.titleText, { color: colors.text }]}>
// {t("trip.infoTrip")}
// </Text>
// <View style={styles.buttonWrapper}>
// <ButtonCreateNewHaulOrTrip />
// </View>
// </View>
// <ScrollView contentContainerStyle={styles.scrollContent}>
// <View style={styles.container}>
// <TripCostTable />
// <FishingToolsTable />
// <CrewListTable />
// <NetListTable />
// <View style={styles.buttonRow}>
// <ButtonCancelTrip />
// <ButtonEndTrip />
// </View>
// </View>
// </ScrollView>
// </SafeAreaView>
// );
// }
// const styles = StyleSheet.create({
// safeArea: {
// flex: 1,
// paddingBottom: 5,
// },
// scrollContent: {
// flexGrow: 1,
// },
// header: {
// width: "100%",
// paddingHorizontal: 15,
// paddingTop: 15,
// paddingBottom: 10,
// alignItems: "center",
// },
// buttonWrapper: {
// width: "100%",
// flexDirection: "row",
// justifyContent: "flex-end",
// },
// container: {
// alignItems: "center",
// paddingHorizontal: 15,
// },
// buttonRow: {
// flexDirection: "row",
// gap: 10,
// marginTop: 15,
// marginBottom: 15,
// },
// titleText: {
// fontSize: 32,
// fontWeight: "700",
// lineHeight: 40,
// paddingBottom: 10,
// fontFamily: Platform.select({
// ios: "System",
// android: "Roboto",
// default: "System",
// }),
// },
// });

View File

@@ -1,42 +1,314 @@
import ShipSearchForm from "@/components/ShipSearchForm"; import { AlarmCard } from "@/components/alarm/AlarmCard";
import { useState } from "react"; import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; 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 [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
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>
<ShipSearchForm <View style={styles.badgeContainer}>
isOpen={shipSearchFormOpen} <TouchableOpacity onPress={toggleSearchForm}>
onClose={() => setShipSearchFormOpen(false)} <Ionicons
size={20}
name="filter-outline"
color={hasFilters ? colors.primary : colors.text}
/> />
</ScrollView> </TouchableOpacity>
</View>
</View>
{/* 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ả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",
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Colors } from "@/config"; import { Colors } from "@/config";
import { queryShipGroups } from "@/controller/DeviceController";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context"; import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { useShipGroups } from "@/state/use-ship-groups";
import { useShipTypes } from "@/state/use-ship-types"; import { useShipTypes } from "@/state/use-ship-types";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
@@ -44,8 +44,8 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
[colors, colorScheme] [colors, colorScheme]
); );
const { shipTypes, getShipTypes } = useShipTypes(); const { shipTypes, getShipTypes } = useShipTypes();
const [groupShips, setGroupShips] = useState<Model.ShipGroup[]>([]);
const [slideAnim] = useState(new Animated.Value(0)); const [slideAnim] = useState(new Animated.Value(0));
const { shipGroups, getShipGroups } = useShipGroups();
const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({ const { control, handleSubmit, reset, watch } = useForm<SearchShipResponse>({
defaultValues: { defaultValues: {
@@ -70,8 +70,10 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
}, [shipTypes]); }, [shipTypes]);
useEffect(() => { useEffect(() => {
if (shipGroups === null) {
getShipGroups(); getShipGroups();
}, []); }
}, [props.isOpen]);
useEffect(() => { useEffect(() => {
if (props.isOpen) { if (props.isOpen) {
@@ -107,17 +109,6 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
} }
}, [props.initialValues]); }, [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 = [ const alarmListLabel = [
{ {
label: "Tiếp cận vùng hạn chế", label: "Tiếp cận vùng hạn chế",
@@ -366,10 +357,12 @@ const ShipSearchForm = (props: ShipSearchFormProps) => {
name="ship_group_id" name="ship_group_id"
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<Select <Select
options={groupShips.map((group) => ({ options={
shipGroups?.map((group) => ({
label: group.name || "", label: group.name || "",
value: group.id || "", value: group.id || "",
}))} })) || []
}
placeholder="Chọn đội tàu" placeholder="Chọn đội tàu"
mode="multiple" mode="multiple"
value={value} value={value}

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

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

View File

@@ -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({
}), }),
}, },
}); });

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
import React, { useState } from "react";
import { View, TextInput, StyleSheet, Platform, StyleProp, ViewStyle } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface SearchBarProps {
onSearch?: (text: string) => void;
style?: StyleProp<ViewStyle>;
}
export default function SearchBar({ onSearch, style }: SearchBarProps) {
const [searchText, setSearchText] = useState("");
const handleChangeText = (text: string) => {
setSearchText(text);
onSearch?.(text);
};
return (
<View style={[styles.container, style]}>
<Ionicons name="search" size={20} color="#9CA3AF" style={styles.icon} />
<TextInput
style={styles.input}
placeholder="Tìm kiếm chuyến đi, tàu..."
placeholderTextColor="#9CA3AF"
value={searchText}
onChangeText={handleChangeText}
/>
{searchText.length > 0 && (
<Ionicons
name="close-circle"
size={20}
color="#9CA3AF"
style={styles.clearIcon}
onPress={() => handleChangeText("")}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#F9FAFB",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
borderWidth: 1,
borderColor: "#E5E7EB",
},
icon: {
marginRight: 8,
},
input: {
flex: 1,
fontSize: 16,
color: "#111827",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
clearIcon: {
marginLeft: 8,
},
});

View File

@@ -0,0 +1,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",
}),
},
});

View File

@@ -9,49 +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: "created", label: "Đã khởi tạo" },
{ value: "pending", label: "Chờ duyệt" },
{ value: "approved", label: "Đã duyệt" },
{ value: "in-progress", label: "Đang hoạt động" },
{ value: "completed", label: "Hoàn thành" },
{ value: "cancelled", label: "Đã hủy" },
];
export default function StatusDropdown({ 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
@@ -65,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>
))} ))}
@@ -104,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",
@@ -116,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)",
@@ -142,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%",
@@ -163,22 +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",
},
}); });

View File

@@ -7,10 +7,14 @@ 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; onView?: () => void;
onEdit?: () => void; onEdit?: () => void;
@@ -19,16 +23,63 @@ interface TripCardProps {
onDelete?: () => void; onDelete?: () => void;
} }
export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend, onDelete }: 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 // Determine which actions to show based on status
const showEdit = trip.status === 'created' || trip.status === 'pending'; const showEdit = trip.trip_status === 0 || trip.trip_status === 1;
const showSend = trip.status === 'created'; const showSend = trip.trip_status === 0;
const showDelete = trip.status === 'pending'; 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 (
<View style={styles.card}> <View style={[styles.card, themedStyles.card]}>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
@@ -39,8 +90,7 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
color={statusConfig.textColor} color={statusConfig.textColor}
/> />
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.title}>{trip.title}</Text> <Text style={[styles.title, themedStyles.title]}>{trip.name}</Text>
<Text style={styles.code}>{trip.code}</Text>
</View> </View>
</View> </View>
<View <View
@@ -67,60 +117,84 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
{/* Info Grid */} {/* Info Grid */}
<View style={styles.infoGrid}> <View style={styles.infoGrid}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={styles.label}>Tàu</Text> <Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipName")}</Text>
<Text style={styles.value}> <Text style={[styles.value, themedStyles.value]}>
{trip.vessel} ({trip.vesselCode}) {thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */}
</Text> </Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={styles.label}>Khởi hành</Text> <Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.departure")}</Text>
<Text style={styles.value}>{trip.departureDate}</Text> <Text style={[styles.value, themedStyles.value]}>
{trip.departure_time
? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm")
: "-"}
</Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={styles.label}>Trở về</Text> <Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.return")}</Text>
<Text style={styles.value}>{trip.returnDate || "-"}</Text> {/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */}
</View> <Text style={[styles.value, themedStyles.value]}>
{trip.arrival_time
<View style={styles.infoRow}> ? dayjs(trip.arrival_time).format("DD/MM/YYYY HH:mm")
<Text style={styles.label}>Thời gian</Text> : "-"}
<Text style={[styles.value, styles.duration]}>{trip.duration}</Text> </Text>
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.divider} /> <View style={[styles.divider, themedStyles.divider]} />
<View style={styles.actionsContainer}> <View style={styles.actionsContainer}>
<TouchableOpacity style={styles.actionButton} onPress={onView} activeOpacity={0.7}> <TouchableOpacity
<Ionicons name="eye-outline" size={20} color="#6B7280" /> style={styles.actionButton}
<Text style={styles.actionText}>View</Text> 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> </TouchableOpacity>
{showEdit && ( {showEdit && (
<TouchableOpacity style={styles.actionButton} onPress={onEdit} activeOpacity={0.7}> <TouchableOpacity
<Ionicons name="create-outline" size={20} color="#6B7280" /> style={styles.actionButton}
<Text style={styles.actionText}>Edit</Text> 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>
)} )}
<TouchableOpacity style={styles.actionButton} onPress={onTeam} activeOpacity={0.7}> <TouchableOpacity
<Ionicons name="people-outline" size={20} color="#6B7280" /> style={styles.actionButton}
<Text style={styles.actionText}>Team</Text> 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> </TouchableOpacity>
{showSend && ( {showSend && (
<TouchableOpacity style={styles.actionButton} onPress={onSend} activeOpacity={0.7}> <TouchableOpacity
<Ionicons name="send-outline" size={20} color="#6B7280" /> style={styles.actionButton}
<Text style={styles.actionText}>Send</Text> 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> </TouchableOpacity>
)} )}
{showDelete && ( {showDelete && (
<TouchableOpacity style={styles.actionButton} onPress={onDelete} activeOpacity={0.7}> <TouchableOpacity
<Ionicons name="trash-outline" size={20} color="#EF4444" /> style={styles.actionButton}
<Text style={[styles.actionText, styles.deleteText]}>Delete</Text> 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> </TouchableOpacity>
)} )}
</View> </View>
@@ -130,26 +204,24 @@ export default function TripCard({ trip, onPress, onView, onEdit, onTeam, onSend
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",
@@ -157,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",
@@ -171,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,
@@ -195,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",
@@ -213,39 +276,30 @@ 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: {
color: "#3B82F6",
},
divider: { divider: {
height: 1, height: 1,
backgroundColor: "#F3F4F6", marginVertical: 8,
marginTop: 16,
}, },
actionsContainer: { actionsContainer: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-around", justifyContent: "space-around",
paddingTop: 12, alignItems: "center",
}, },
actionButton: { actionButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 6, gap: 4,
paddingVertical: 8, paddingVertical: 4,
paddingHorizontal: 12,
}, },
actionText: { actionText: {
fontSize: 14, fontSize: 14,
color: "#6B7280",
fontWeight: "500",
fontFamily: Platform.select({ fontFamily: Platform.select({
ios: "System", ios: "System",
android: "Roboto", android: "Roboto",

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View 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",
}),
},
});

View File

@@ -1,92 +0,0 @@
import { Trip } from "./types";
export const MOCK_TRIPS: Trip[] = [
{
id: "T001",
title: "Chuyến đi Hoàng Sa",
code: "T001",
vessel: "Hải Âu 1",
vesselCode: "V001",
departureDate: "2025-11-20 06:00",
returnDate: "2025-11-27 18:30",
duration: "7 ngày 12 giờ",
status: "completed",
},
{
id: "T002",
title: "Tuần tra vùng biển",
code: "T002",
vessel: "Bình Minh",
vesselCode: "V004",
departureDate: "2025-11-26 08:00",
returnDate: null,
duration: "2 ngày 6 giờ",
status: "in-progress",
},
{
id: "T003",
title: "Đánh cá Trường Sa",
code: "T003",
vessel: "Ngọc Lan",
vesselCode: "V002",
departureDate: "2025-11-15 05:30",
returnDate: "2025-11-25 16:00",
duration: "10 ngày 10 giờ",
status: "completed",
},
{
id: "T004",
title: "Vận chuyển hàng hóa",
code: "T004",
vessel: "Việt Thắng",
vesselCode: "V003",
departureDate: "2025-11-22 10:00",
returnDate: null,
duration: "-",
status: "cancelled",
},
{
id: "T005",
title: "Khảo sát địa chất",
code: "T005",
vessel: "Thanh Bình",
vesselCode: "V005",
departureDate: "2025-11-18 07:00",
returnDate: "2025-11-23 14:00",
duration: "5 ngày 7 giờ",
status: "created",
},
{
id: "T006",
title: "Đánh cá ven bờ",
code: "T006",
vessel: "Hải Âu 1",
vesselCode: "V001",
departureDate: "2025-11-28 04:00",
returnDate: null,
duration: "6 giờ",
status: "in-progress",
},
{
id: "T007",
title: "Khảo sát vùng biển",
code: "T007",
vessel: "Ngọc Lan",
vesselCode: "V002",
departureDate: "2025-12-01 07:00",
returnDate: null,
duration: "-",
status: "pending",
},
{
id: "T008",
title: "Đánh cá xa bờ",
code: "T008",
vessel: "Việt Thắng",
vesselCode: "V003",
departureDate: "2025-12-05 05:00",
returnDate: null,
duration: "-",
status: "approved",
},
];

View File

@@ -1,58 +1,93 @@
import { useI18n } from "@/hooks/use-i18n";
export type TripStatus = export type TripStatus =
| "created" // Đã khởi tạo | 0 // Đã khởi tạo
| "pending" // Chờ duyệt | 1 // Chờ duyệt
| "approved" // Đã duyệt | 2 // Đã duyệt
| "in-progress" // Đang hoạt động | 3 // Đang hoạt động
| "completed" // Hoàn thành | 4 // Hoàn thành
| "cancelled"; // Đã hủy | 5; // Đã hủy
export interface Trip {
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 = {
created: { 0: {
label: "Đã khởi tạo", label: "Đã khởi tạo",
bgColor: "#F3F4F6", // Gray background bgColor: "#F3F4F6", // Gray background
textColor: "#4B5563", // Gray text textColor: "#4B5563", // Gray text
icon: "document-text", icon: "document-text",
}, },
pending: { 1: {
label: "Chờ duyệt", label: "Chờ duyệt",
bgColor: "#FEF3C7", // Yellow background bgColor: "#FEF3C7", // Yellow background
textColor: "#92400E", // Dark yellow text textColor: "#92400E", // Dark yellow text
icon: "hourglass", icon: "hourglass",
}, },
approved: { 2: {
label: "Đã duyệt", label: "Đã duyệt",
bgColor: "#E0E7FF", // Indigo background bgColor: "#E0E7FF", // Indigo background
textColor: "#3730A3", // Dark indigo text textColor: "#3730A3", // Dark indigo text
icon: "checkmark-done", icon: "checkmark-done",
}, },
"in-progress": { 3: {
label: "Đang hoạt động", label: "Đang hoạt động",
bgColor: "#DBEAFE", // Blue background bgColor: "#DBEAFE", // Blue background
textColor: "#1E40AF", // Dark blue text textColor: "#1E40AF", // Dark blue text
icon: "sync", icon: "sync",
}, },
completed: { 4: {
label: "Hoàn thành", label: "Hoàn thành",
bgColor: "#D1FAE5", // Green background bgColor: "#D1FAE5", // Green background
textColor: "#065F46", // Dark green text textColor: "#065F46", // Dark green text
icon: "checkmark-circle", icon: "checkmark-circle",
}, },
cancelled: { 5: {
label: "Đã hủy", label: "Đã hủy",
bgColor: "#FEE2E2", // Red background bgColor: "#FEE2E2", // Red background
textColor: "#991B1B", // Dark red text textColor: "#991B1B", // Dark red text
icon: "close-circle", icon: "close-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;
}

View File

@@ -0,0 +1,16 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { StyleSheet } from "react-native";
export default function DevicesScreen() {
console.log("Gọi API 2");
return (
<ThemedView style={styles.container}>
<ThemedText>Quản thiết bị</ThemedText>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});

View File

@@ -0,0 +1,17 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { StyleSheet } from "react-native";
export default function FleetsScreen() {
console.log("Gọi API 3");
return (
<ThemedView style={styles.container}>
<ThemedText>Quản đi tàu</ThemedText>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});

View File

@@ -0,0 +1,853 @@
import Select, { SelectOption } from "@/components/Select";
import { ThemedText } from "@/components/themed-text";
import { Colors } from "@/config";
import { ColorScheme, useTheme } from "@/hooks/use-theme-context";
import { usePort } from "@/state/use-ports";
import { useShipGroups } from "@/state/use-ship-groups";
import { useShipTypes } from "@/state/use-ship-types";
import { useThings } from "@/state/use-thing";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
interface CreateOrUpdateShipProps {
initialValue?: Model.ShipBodyRequest;
isOpen?: boolean;
type?: "create" | "update";
onSubmit?: (data: Model.ShipBodyRequest) => void;
onClose?: () => void;
}
const CreateOrUpdateShip = (props: CreateOrUpdateShipProps) => {
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const { shipTypes, getShipTypes } = useShipTypes();
const { ports, getPorts } = usePort();
const { shipGroups, getShipGroups } = useShipGroups();
const { things, getThings } = useThings();
// State for date picker
const [showDatePicker, setShowDatePicker] = useState(false);
// Initialize form with react-hook-form
const {
control,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm<Model.ShipBodyRequest>({
defaultValues: props.initialValue || {
fishing_license_expiry_date: new Date(),
},
});
// Watch the date field for picker display
const dateValue = watch("fishing_license_expiry_date");
// Fetch data when modal opens
useEffect(() => {
if (props.isOpen) {
// Fetch ship types if not loaded
if (shipTypes === null || shipTypes.length === 0) {
getShipTypes();
}
// Fetch ports if not loaded
if (ports === null) {
getPorts();
}
// Fetch ship groups if not loaded
if (shipGroups === null) {
getShipGroups();
}
// Fetch things when modal opens
const payloadThings: Model.SearchThingBody = {
offset: 0,
limit: 200,
order: "name",
dir: "asc",
};
getThings(payloadThings);
// Reset form with initial values if provided
if (props.initialValue) {
reset(props.initialValue);
}
}
}, [props.isOpen, props.initialValue, reset]);
useEffect(() => {
if (props.type === "create") {
reset(props.initialValue);
}
}, [props.isOpen]);
// Prepare options for selects
const shipTypeOptions = useMemo<SelectOption[]>(() => {
return (shipTypes || []).map((type) => ({
label: type.name || "",
value: type.id || 0,
}));
}, [shipTypes]);
const portOptions = useMemo<SelectOption[]>(() => {
return (ports?.ports || []).map((port) => ({
label: port.name || "",
value: port.id || 0,
}));
}, [ports]);
const shipGroupOptions = useMemo<SelectOption[]>(() => {
return (shipGroups || []).map((group) => ({
label: group.name || "",
value: group.id || "",
}));
}, [shipGroups]);
const thingOptions = useMemo<SelectOption[]>(() => {
// Filter things that are not assigned to any ship
const unassignedThings = (things || []).filter(
(thing) => !thing.metadata?.ship_id
);
return unassignedThings.map((thing) => ({
label: thing.name || "",
value: thing.id || "",
}));
}, [things]);
// Handle date picker change
const handleDateChange = (_: any, selectedDate?: Date) => {
if (selectedDate) {
setValue("fishing_license_expiry_date", selectedDate);
}
// On Android, close picker after selection
// On iOS, keep it open until user confirms with the button
if (Platform.OS === "android") {
setShowDatePicker(false);
}
};
// Format date for display
const formatDateForDisplay = (date: Date | string | undefined) => {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
// Handle form submission
const onSubmit = (data: Model.ShipBodyRequest) => {
// Ensure numeric fields are numbers
const payload: Model.ShipBodyRequest = {
...data,
ship_type: Number(data.ship_type),
home_port: Number(data.home_port),
ship_length: Number(data.ship_length),
ship_power: Number(data.ship_power),
fishing_license_expiry_date: data.fishing_license_expiry_date,
};
props.onSubmit?.(payload);
};
return (
<Modal
animationType="slide"
transparent={true}
visible={props.isOpen}
onRequestClose={props.onClose}
>
<SafeAreaView style={{ flex: 1 }} edges={["top", "left", "right"]}>
<View style={styles.container}>
<Pressable style={styles.backdrop} onPress={props.onClose} />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.keyboardAvoidingView}
>
<View style={styles.modalContent}>
{/* Header */}
<View style={styles.header}>
<View style={styles.dragIndicator} />
<ThemedText style={styles.headerTitle}>
{props.type === "create" ? "Thêm tàu mới" : "Cập nhật tàu"}
</ThemedText>
<TouchableOpacity
onPress={props.onClose}
style={styles.closeButton}
>
<ThemedText style={styles.closeButtonText}></ThemedText>
</TouchableOpacity>
</View>
{/* Form Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Registration Number - Only show in create mode */}
{props.type === "create" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Số đăng *</ThemedText>
<Controller
control={control}
name="reg_number"
rules={{ required: "Vui lòng nhập số đăng ký" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.reg_number
? "red"
: colors.border,
},
]}
placeholder="Nhập số đăng ký"
onBlur={onBlur}
onChangeText={(text) => onChange(text.trim())}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.reg_number && (
<ThemedText style={styles.errorText}>
{errors.reg_number.message}
</ThemedText>
)}
</View>
)}
{/* Ship Name */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Tên tàu *</ThemedText>
<Controller
control={control}
name="name"
rules={{ required: "Vui lòng nhập tên tàu" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{ borderColor: errors.name ? "red" : colors.border },
]}
placeholder="Nhập tên tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.name && (
<ThemedText style={styles.errorText}>
{errors.name.message}
</ThemedText>
)}
</View>
{/* Ship Type */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Loại tàu *</ThemedText>
<Controller
control={control}
name="ship_type"
rules={{ required: "Vui lòng chọn loại tàu" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={shipTypeOptions}
placeholder="Chọn loại tàu"
style={[
styles.selectInput,
{
borderColor: errors.ship_type
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.ship_type && (
<ThemedText style={styles.errorText}>
{errors.ship_type.message}
</ThemedText>
)}
</View>
{/* Home Port */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Cảng đăng đ *
</ThemedText>
<Controller
control={control}
name="home_port"
rules={{ required: "Vui lòng chọn cảng đăng ký" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={portOptions}
placeholder="Chọn cảng đăng ký"
style={[
styles.selectInput,
{
borderColor: errors.home_port
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.home_port && (
<ThemedText style={styles.errorText}>
{errors.home_port.message}
</ThemedText>
)}
</View>
{/* Fishing License Number */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Số giấy phép *</ThemedText>
<Controller
control={control}
name="fishing_license_number"
rules={{ required: "Vui lòng nhập số giấy phép" }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.fishing_license_number
? "red"
: colors.border,
},
]}
placeholder="Nhập số giấy phép"
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.fishing_license_number && (
<ThemedText style={styles.errorText}>
{errors.fishing_license_number.message}
</ThemedText>
)}
</View>
{/* Fishing License Expiry Date */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Ngày hết hạn *</ThemedText>
<Controller
control={control}
name="fishing_license_expiry_date"
rules={{
required: "Vui lòng chọn ngày hết hạn",
validate: (date) => {
if (!date) return "Vui lòng chọn ngày hết hạn";
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
return "Ngày hết hạn không thể là ngày trong quá khứ";
}
return true;
},
}}
render={({ field: { onChange, value } }) => (
<TouchableOpacity
onPress={() => setShowDatePicker(true)}
style={[
styles.input,
styles.dateInput,
{
borderColor: errors.fishing_license_expiry_date
? "red"
: colors.border,
},
]}
>
<ThemedText
style={{
color: value ? colors.text : colors.textSecondary,
}}
>
{formatDateForDisplay(value) || "Chọn ngày hết hạn"}
</ThemedText>
</TouchableOpacity>
)}
/>
{errors.fishing_license_expiry_date && (
<ThemedText style={styles.errorText}>
{errors.fishing_license_expiry_date.message}
</ThemedText>
)}
</View>
{/* Ship Length */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Chiều dài (m) *</ThemedText>
<Controller
control={control}
name="ship_length"
rules={{
required: "Vui lòng nhập chiều dài",
pattern: {
value: /^\d*\.?\d+$/,
message: "Vui lòng nhập số hợp lệ",
},
validate: (value) => {
const num = Number(value);
if (isNaN(num) || num <= 0) {
return "Chiều dài phải lớn hơn 0";
}
return true;
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.ship_length
? "red"
: colors.border,
},
]}
placeholder="Nhập chiều dài tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value?.toString()}
keyboardType="numeric"
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.ship_length && (
<ThemedText style={styles.errorText}>
{errors.ship_length.message}
</ThemedText>
)}
</View>
{/* Ship Power */}
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Công suất ( lực) *
</ThemedText>
<Controller
control={control}
name="ship_power"
rules={{
required: "Vui lòng nhập công suất",
pattern: {
value: /^\d*\.?\d+$/,
message: "Vui lòng nhập số hợp lệ",
},
validate: (value) => {
const num = Number(value);
if (isNaN(num) || num <= 0) {
return "Công suất phải lớn hơn 0";
}
return true;
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[
styles.input,
{
borderColor: errors.ship_power
? "red"
: colors.border,
},
]}
placeholder="Nhập công suất tàu"
onBlur={onBlur}
onChangeText={onChange}
value={value?.toString()}
keyboardType="numeric"
placeholderTextColor={colors.textSecondary}
/>
)}
/>
{errors.ship_power && (
<ThemedText style={styles.errorText}>
{errors.ship_power.message}
</ThemedText>
)}
</View>
{/* Ship Group - Only show in update mode */}
{props.type === "update" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>Đi tàu</ThemedText>
<Controller
control={control}
name="ship_group_id"
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={shipGroupOptions}
placeholder="Chọn đội tàu"
style={styles.selectInput}
/>
)}
/>
</View>
)}
{/* Device/Thing - Only show in create mode */}
{props.type === "create" && (
<View style={styles.fieldGroup}>
<ThemedText style={styles.label}>
Thiết bị kết nối *
</ThemedText>
<Controller
control={control}
name="thing_id"
rules={{ required: "Vui lòng chọn thiết bị kết nối" }}
render={({ field: { onChange, value } }) => (
<Select
value={value}
onChange={onChange}
options={thingOptions}
placeholder="Chọn thiết bị kết nối"
style={[
styles.selectInput,
{
borderColor: errors.thing_id
? "red"
: colors.border,
},
]}
/>
)}
/>
{errors.thing_id && (
<ThemedText style={styles.errorText}>
{errors.thing_id.message}
</ThemedText>
)}
</View>
)}
</ScrollView>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
style={[styles.resetButton, { borderColor: colors.border }]}
onPress={() => {
reset(props.initialValue || {});
}}
>
<ThemedText
style={[styles.resetButtonText, { color: colors.text }]}
>
Nhập lại
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onSubmit)}
>
<ThemedText style={styles.submitButtonText}>
{props.type === "create" ? "Thêm tàu" : "Cập nhật"}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</SafeAreaView>
{/* Date Picker Modal - Only show on Android as modal, iOS shows inline */}
{Platform.OS === "android" && showDatePicker && (
<DateTimePicker
value={
typeof dateValue === "string"
? new Date(dateValue)
: dateValue || new Date()
}
mode="date"
display="default"
onChange={handleDateChange}
minimumDate={new Date()}
/>
)}
{Platform.OS === "ios" && showDatePicker && (
<Modal
transparent={true}
animationType="fade"
visible={showDatePicker}
onRequestClose={() => setShowDatePicker(false)}
>
<SafeAreaView style={styles.datePickerModal}>
<View style={styles.datePickerContent}>
<View style={styles.datePickerHeader}>
<ThemedText style={styles.datePickerTitle}>
Chọn ngày hết hạn
</ThemedText>
<TouchableOpacity onPress={() => setShowDatePicker(false)}>
<ThemedText style={styles.datePickerClose}></ThemedText>
</TouchableOpacity>
</View>
<DateTimePicker
value={
typeof dateValue === "string"
? new Date(dateValue)
: dateValue || new Date()
}
mode="date"
display="spinner"
onChange={handleDateChange}
themeVariant={colorScheme}
textColor={colors.text}
minimumDate={new Date()}
style={styles.datePickerIOS}
/>
<TouchableOpacity
style={[
styles.datePickerButton,
{ backgroundColor: colors.primary },
]}
onPress={() => setShowDatePicker(false)}
>
<ThemedText style={styles.datePickerButtonText}>
Xác nhận
</ThemedText>
</TouchableOpacity>
</View>
</SafeAreaView>
</Modal>
)}
</Modal>
);
};
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
StyleSheet.create({
container: {
flex: 1,
position: "relative",
},
keyboardAvoidingView: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.3)",
},
modalContent: {
height: "90%",
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",
color: colors.text,
},
closeButton: {
position: "absolute",
right: 16,
top: 16,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
},
closeButtonText: {
fontSize: 20,
fontWeight: "300",
color: colors.text,
},
scrollView: {
flex: 1,
padding: 20,
},
scrollContent: {
paddingBottom: Platform.OS === "ios" ? 120 : 80,
},
fieldGroup: {
marginBottom: 24,
},
label: {
fontSize: 15,
fontWeight: "600",
marginBottom: 8,
color: colors.text,
},
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
backgroundColor: colors.surface,
color: colors.text,
},
selectInput: {
borderWidth: 1,
borderRadius: 12,
backgroundColor: colors.surface,
},
dateInput: {
justifyContent: "center",
},
errorText: {
fontSize: 13,
color: "red",
marginTop: 4,
},
actionButtons: {
flexDirection: "row",
paddingHorizontal: 20,
paddingVertical: 16,
gap: 12,
borderTopWidth: 1,
borderTopColor: colors.border,
},
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",
},
// Date Picker Modal Styles
datePickerModal: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
datePickerContent: {
backgroundColor: colors.background,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingBottom: 20,
},
datePickerHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
datePickerTitle: {
fontSize: 16,
fontWeight: "600",
color: colors.text,
},
datePickerClose: {
fontSize: 20,
color: colors.text,
},
datePickerIOS: {
height: 200,
marginTop: 20,
},
datePickerButton: {
marginHorizontal: 20,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
marginTop: 20,
},
datePickerButtonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
});
export default CreateOrUpdateShip;

View File

@@ -0,0 +1,522 @@
import { queryShipsImage } from "@/controller/DeviceController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useGroup } from "@/state/use-group";
import { usePort } from "@/state/use-ports";
import { useShipTypes } from "@/state/use-ship-types";
import { Ionicons } from "@expo/vector-icons";
import { fromByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
interface ShipCardProps {
ship: Model.Ship;
onPress?: () => void;
}
export default function ShipCard({ ship, onPress }: ShipCardProps) {
const { colors } = useThemeContext();
const { ports, getPorts } = usePort();
const { shipTypes, getShipTypes } = useShipTypes();
const [shipImage, setShipImage] = useState<string | null>(null);
const { groups, getUserGroups, getChildrenOfGroups, childrenOfGroups } =
useGroup();
useEffect(() => {
if (ports === null) {
getPorts();
}
}, [ports, getPorts]);
useEffect(() => {
if (!shipTypes || shipTypes.length === 0) {
getShipTypes();
}
}, [shipTypes, getShipTypes]);
useEffect(() => {
if (groups === null) {
getUserGroups();
}
}, [groups, getUserGroups]);
useEffect(() => {
if (groups && ship.ship_group_id) {
const groupId = groups?.groups?.[0]?.id || "";
// childrenOfGroups is initialised as null in the store; check for null to fetch once
if (groupId && childrenOfGroups == null) {
getChildrenOfGroups(groupId);
}
}
}, [groups, childrenOfGroups, getChildrenOfGroups]);
// Themed styles
useEffect(() => {
let mounted = true;
const loadShipImage = async () => {
try {
const resp = await queryShipsImage(ship.id || "");
const contentType = resp.headers["content-type"] || "image/jpeg";
const uint8 = new Uint8Array(resp.data); // ArrayBuffer -> Uint8Array
const base64 = fromByteArray(uint8); // base64-js
const uri = `data:${contentType};base64,${base64}`;
if (!mounted) return;
// assign received value to state if present; adapt to actual resp shape as needed
setShipImage(uri);
} catch (error) {
// console.log("Error when get image: ", error);
}
};
loadShipImage();
return () => {
mounted = false;
};
}, [ship]);
const themedStyles = {
card: {
backgroundColor: colors.card,
shadowColor: colors.text,
},
title: {
color: colors.text,
},
subtitle: {
color: colors.textSecondary,
},
label: {
color: colors.textSecondary,
},
value: {
color: colors.text,
},
divider: {
backgroundColor: colors.separator,
},
badge: {
backgroundColor: colors.primary + "15",
borderColor: colors.primary,
},
badgeText: {
color: colors.primary,
},
infoBox: {
backgroundColor: colors.primary + "10",
},
infoIcon: {
color: colors.primary,
},
};
// ============ IMAGE VARIANT ============
if (shipImage) {
return (
<TouchableOpacity
style={[styles.imageCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Image Section */}
<View style={styles.imageContainer}>
{shipImage ? (
<Image
source={{ uri: shipImage }}
style={styles.shipImage}
resizeMode="cover"
/>
) : (
<View
style={[
styles.imagePlaceholder,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<Ionicons name="boat" size={48} color={colors.textSecondary} />
</View>
)}
{/* Ship Type Badge */}
{shipTypes && (
<View style={styles.typeBadge}>
<Ionicons name="boat-outline" size={14} color="#fff" />
<Text style={styles.typeBadgeText}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
)}
</View>
{/* Info Section */}
<View style={styles.imageCardContent}>
{/* Title & Registration */}
<Text style={[styles.imageCardTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<View style={styles.regRow}>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
{childrenOfGroups && (
<View style={styles.locationRow}>
<Ionicons
name="location"
size={14}
color={colors.textSecondary}
/>
<Text style={[styles.locationText, themedStyles.subtitle]}>
{childrenOfGroups.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"}
</Text>
</View>
)}
</View>
{/* Info Grid */}
<View style={styles.imageInfoGrid}>
<InfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="flash"
label="Engine Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<InfoBox
icon="navigate"
label="Home Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)
?.name || "-"
: "-"
}
themedStyles={themedStyles}
/>
</View>
</View>
</TouchableOpacity>
);
}
// ============ COMPACT VARIANT ============
return (
<TouchableOpacity
style={[styles.compactCard, themedStyles.card]}
onPress={onPress}
activeOpacity={0.8}
>
{/* Header */}
<View style={styles.compactHeader}>
<View style={[styles.shipIcon, themedStyles.infoBox]}>
<Ionicons name="boat" size={24} color={colors.primary} />
</View>
<View style={styles.compactHeaderText}>
<Text style={[styles.compactTitle, themedStyles.title]}>
{ship.name || "Unknown Ship"}
</Text>
<Text style={[styles.compactSubtitle, themedStyles.subtitle]}>
{shipTypes.find((type) => type.id === ship.ship_type)?.name ||
"Unknown"}
</Text>
</View>
<View style={[styles.regBadge, themedStyles.badge]}>
<Text style={[styles.regBadgeText, themedStyles.badgeText]}>
{ship.reg_number || "-"}
</Text>
</View>
</View>
{/* Info Grid */}
<View style={styles.compactInfoGrid}>
<CompactInfoBox
icon="resize"
label="Length"
value={ship.ship_length ? `${ship.ship_length}m` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="flash"
label="Power"
value={ship.ship_power ? `${ship.ship_power} HP` : "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="navigate"
label="Port"
value={
ports?.ports
? ports.ports.find((port) => port.id === ship.home_port)?.name ||
"-"
: "-"
}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="document-text"
label="License"
value={ship.fishing_license_number || "-"}
themedStyles={themedStyles}
/>
<CompactInfoBox
icon="location"
label="Province"
value={
childrenOfGroups?.groups?.find(
(group) => group?.metadata?.code === ship.province_code
)?.name || "-"
}
themedStyles={themedStyles}
/>
</View>
{/* Footer - IMO & MMSI */}
{(ship.imo_number || ship.mmsi_number) && (
<>
<View style={[styles.divider, themedStyles.divider]} />
<View style={styles.footerInfo}>
{ship.imo_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
IMO Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.imo_number}
</Text>
</View>
)}
{ship.mmsi_number && (
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, themedStyles.label]}>
MMSI Number:
</Text>
<Text style={[styles.footerValue, themedStyles.value]}>
{ship.mmsi_number}
</Text>
</View>
)}
</View>
</>
)}
</TouchableOpacity>
);
}
// ============ SUB-COMPONENTS ============
interface InfoBoxProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
value: string;
themedStyles: any;
}
function InfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={styles.infoBox}>
<Ionicons name={icon} size={18} color={themedStyles.infoIcon.color} />
<Text style={[styles.infoLabel, themedStyles.label]}>{label}</Text>
<Text style={[styles.infoValue, themedStyles.value]}>{value}</Text>
</View>
);
}
function CompactInfoBox({ icon, label, value, themedStyles }: InfoBoxProps) {
return (
<View style={[styles.compactInfoBox, themedStyles.infoBox]}>
<Ionicons name={icon} size={16} color={themedStyles.infoIcon.color} />
<View style={styles.compactInfoText}>
<Text style={[styles.compactInfoLabel, themedStyles.label]}>
{label}
</Text>
<Text style={[styles.compactInfoValue, themedStyles.value]}>
{value}
</Text>
</View>
</View>
);
}
// ============ STYLES ============
const styles = StyleSheet.create({
// === IMAGE VARIANT ===
imageCard: {
borderRadius: 16,
overflow: "hidden",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
marginVertical: 8,
marginHorizontal: 16,
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
},
imageContainer: {
height: 180,
position: "relative",
},
shipImage: {
width: "100%",
height: "100%",
},
imagePlaceholder: {
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
},
typeBadge: {
position: "absolute",
top: 12,
left: 12,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(59, 130, 246, 0.9)",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
gap: 6,
},
typeBadgeText: {
color: "#fff",
fontSize: 12,
fontWeight: "600",
},
imageCardContent: {
padding: 16,
},
imageCardTitle: {
fontSize: 18,
fontWeight: "700",
marginBottom: 8,
},
regRow: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 16,
},
regBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
borderWidth: 1,
},
regBadgeText: {
fontSize: 12,
fontWeight: "600",
},
locationRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
locationText: {
fontSize: 13,
},
imageInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
},
infoBox: {
width: "47%",
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 8,
},
infoLabel: {
fontSize: 12,
},
infoValue: {
fontSize: 14,
fontWeight: "600",
marginLeft: "auto",
},
// === COMPACT VARIANT ===
compactCard: {
borderRadius: 16,
padding: 16,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 2,
marginVertical: 8,
marginHorizontal: 16,
},
compactHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
},
shipIcon: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
compactHeaderText: {
flex: 1,
marginLeft: 12,
},
compactTitle: {
fontSize: 16,
fontWeight: "700",
},
compactSubtitle: {
fontSize: 13,
marginTop: 2,
},
compactInfoGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 10,
},
compactInfoBox: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
gap: 8,
minWidth: "47%",
flexGrow: 1,
},
compactInfoText: {
flex: 1,
},
compactInfoLabel: {
fontSize: 11,
},
compactInfoValue: {
fontSize: 14,
fontWeight: "600",
},
divider: {
height: 1,
marginVertical: 12,
},
footerInfo: {
gap: 6,
},
footerRow: {
flexDirection: "row",
justifyContent: "space-between",
},
footerLabel: {
fontSize: 13,
},
footerValue: {
fontSize: 13,
fontWeight: "500",
},
});

View File

@@ -0,0 +1,145 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import {
queryCreateShip,
queryUpdateShip,
} from "@/controller/DeviceController";
import { useTheme } from "@/hooks/use-theme-context";
import { showSuccessToast } from "@/services/toast_service";
import { useShip } from "@/state/use-ship";
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native";
import CreateOrUpdateShip from "./ship_components/CreateOrUpdateShip";
import ShipCard from "./ship_components/ShipCard";
interface ShipUpdateData {
ship_id: string;
body: Model.ShipBodyRequest;
}
export default function ShipsScreen() {
const { ships, getShip } = useShip();
const { colors } = useTheme();
const [ship, setShip] = useState<ShipUpdateData | null>(null);
const [showUpdateShip, setShowUpdateShip] = useState<boolean>(false);
const [isCreateShip, setIsCreateShip] = useState<boolean>(false);
useEffect(() => {
if (ships === null) {
getShip();
}
}, [ships]);
const handleClickShip = async (ship: Model.Ship) => {
const shipBodyRequest: Model.ShipBodyRequest = {
name: ship.name,
reg_number: ship.reg_number,
imo_number: ship.imo_number,
mmsi_number: ship.mmsi_number,
thing_id: ship.thing_id,
ship_type: ship.ship_type,
owner_id: ship.owner_id,
home_port: ship.home_port,
ship_length: ship.ship_length,
ship_power: ship.ship_power,
ship_group_id: ship.ship_group_id,
fishing_license_number: ship.fishing_license_number,
fishing_license_expiry_date: ship.fishing_license_expiry_date,
};
setShip({ ship_id: ship.id!, body: shipBodyRequest });
setIsCreateShip(false); // Đảm bảo là mode update khi edit
setShowUpdateShip(true);
};
const handleSubmitForm = async (body: Model.ShipBodyRequest) => {
try {
let resp;
if (isCreateShip) {
resp = await queryCreateShip(body);
if (resp.status === 201) {
showSuccessToast("Thêm tàu mới thành công");
}
} else {
resp = await queryUpdateShip(ship!.ship_id, body);
if (resp.status === 200) {
showSuccessToast("Cập nhật thông tin tàu thành công");
}
}
setShowUpdateShip(false);
await getShip();
} catch (error) {
console.error("Error when update/create Ship: ", error);
}
};
const handleCreateNewShip = () => {
setShip(null);
setIsCreateShip(true);
setShowUpdateShip(true);
};
return (
<>
<ScrollView>
<ThemedView style={styles.container}>
{ships?.map((ship) => (
<ShipCard
key={ship.id}
ship={ship}
onPress={() => handleClickShip(ship)}
/>
))}
{/* Thêm khoảng trống ở cuối để không bị FAB che */}
<View style={styles.bottomPadding} />
</ThemedView>
</ScrollView>
{/* Floating Action Button */}
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={handleCreateNewShip}
>
<ThemedText style={styles.fabText}>+</ThemedText>
</TouchableOpacity>
<CreateOrUpdateShip
isOpen={showUpdateShip}
initialValue={ship?.body || undefined}
type={isCreateShip ? "create" : "update"}
onClose={() => setShowUpdateShip(false)}
onSubmit={handleSubmitForm}
/>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: 20, // Khoảng trống cho FAB
},
bottomPadding: {
height: 50, // Thêm khoảng trống ở cuối
},
fab: {
position: "absolute",
bottom: 30,
right: 20,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
fabText: {
fontSize: 24,
fontWeight: "bold",
color: "#ffffff",
lineHeight: 24,
},
});

View 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}
/>
);
}

View 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,
},
});

View 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",
},
});

View File

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

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

View File

@@ -72,7 +72,11 @@ api.interceptors.response.use(
statusText || statusText ||
"Unknown error"; "Unknown error";
// Không hiển thị toast cho status 400 (validation errors)
if (status !== 400) {
showErrorToast(`Lỗi ${status}: ${errMsg}`); showErrorToast(`Lỗi ${status}: ${errMsg}`);
}
if (status === 401) { if (status === 401) {
handle401(); handle401();
} }

View File

@@ -1,5 +1,7 @@
export const TOKEN = "token"; export const TOKEN = "token";
export const DOMAIN = "domain"; export const DOMAIN = "domain";
export const UID = "user-id";
export const ROLE = "role";
export const MAP_TRACKPOINTS_ID = "ship-trackpoints"; export const MAP_TRACKPOINTS_ID = "ship-trackpoints";
export const MAP_POLYLINE_BAN = "ban-polyline"; export const MAP_POLYLINE_BAN = "ban-polyline";
export const MAP_POLYGON_BAN = "ban-polygon"; export const MAP_POLYGON_BAN = "ban-polygon";
@@ -36,12 +38,14 @@ 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_GET_PROFILE = "/api/users/profile";
export const API_PATH_SEARCH_THINGS = "/api/things/search"; 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";
@@ -53,3 +57,9 @@ 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_TYPES = "/api/sgw/ships/types";
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup"; 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";
export const API_GET_ALL_SHIP = "/api/sgw/ships";
export const API_GET_ALL_PORT = "/api/sgw/ports";
export const API_GET_PHOTO = "/api/sgw/photo";

View 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 });
}

View File

@@ -1,6 +1,10 @@
import { api } from "@/config"; import { api } from "@/config";
import { API_PATH_LOGIN } from "@/constants"; import { API_PATH_GET_PROFILE, API_PATH_LOGIN } from "@/constants";
export async function queryLogin(body: Model.LoginRequestBody) { export async function queryLogin(body: Model.LoginRequestBody) {
return api.post<Model.LoginResponse>(API_PATH_LOGIN, body); return api.post<Model.LoginResponse>(API_PATH_LOGIN, body);
} }
export async function queryProfile() {
return api.get<Model.ProfileResponse>(API_PATH_GET_PROFILE);
}

View File

@@ -1,43 +1,11 @@
import { api } from "@/config"; import { api } from "@/config";
import { import {
API_GET_ALARMS, API_GET_ALL_SHIP,
API_GET_GPS, API_GET_PHOTO,
API_GET_SHIP_GROUPS, API_GET_SHIP_GROUPS,
API_GET_SHIP_TYPES, API_GET_SHIP_TYPES,
API_PATH_ENTITIES,
API_PATH_SEARCH_THINGS, API_PATH_SEARCH_THINGS,
API_PATH_SHIP_TRACK_POINTS,
API_SOS,
} from "@/constants"; } from "@/constants";
import { transformEntityResponse } from "@/utils/tranform";
export async function queryGpsData() {
return api.get<Model.GPSResponse>(API_GET_GPS);
}
export async function queryAlarm() {
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
}
export async function queryTrackPoints() {
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
}
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 });
}
export async function querySearchThings(body: Model.SearchThingBody) { export async function querySearchThings(body: Model.SearchThingBody) {
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body); return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
@@ -50,3 +18,25 @@ export async function queryShipTypes() {
export async function queryShipGroups() { export async function queryShipGroups() {
return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS); return await api.get<Model.ShipGroup[]>(API_GET_SHIP_GROUPS);
} }
export async function queryAllShips(params: Model.SearchThingBody) {
return await api.get<Model.ShipResponse>(API_GET_ALL_SHIP, {
params: params,
});
}
export async function queryShipsImage(ship_id: string) {
return await api.get(`${API_GET_PHOTO}/ship/${ship_id}/main`, {
responseType: "arraybuffer",
});
}
export async function queryCreateShip(body: Model.ShipBodyRequest) {
return await api.post(`${API_GET_ALL_SHIP}`, body);
}
export async function queryUpdateShip(
shipId: string,
body: Model.ShipBodyRequest
) {
return await api.put(`${API_GET_ALL_SHIP}/${shipId}`, body);
}

View File

@@ -0,0 +1,25 @@
import { api } from "@/config";
import { UID } from "@/constants";
import { getStorageItem } from "@/utils/storage";
export async function queryUserGroup() {
const user_id = await getStorageItem(UID);
return api.get<Model.GroupResponse>(`/api/members/${user_id}/groups`);
}
export async function queryChilrentOfGroups(
group_id: string,
level: number = 5,
isTree: boolean = false
) {
// ensure proper query param values when not provided by caller
const lvl = typeof level === "number" ? level : 5;
const tree = !!isTree;
const params = {
level: lvl,
tree: tree,
};
return api.get<Model.GroupResponse>(`/api/groups/${group_id}/children`, {
params,
});
}

View File

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

View File

@@ -0,0 +1,6 @@
import { api } from "@/config";
import { API_GET_ALL_PORT } from "@/constants";
export async function queryPorts(body?: Model.SearchThingBody) {
return api.post<Model.PortResponse>(API_GET_ALL_PORT, body);
}

View File

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

View File

@@ -1,5 +1,17 @@
import * as AlarmController from "./AlarmController";
import * as AuthController from "./AuthController"; import * as AuthController from "./AuthController";
import * as DeviceController from "./DeviceController"; import * as DeviceController from "./DeviceController";
import * as FishController from "./FishController";
import * as MapController from "./MapController"; import * as MapController from "./MapController";
import * as PortController from "./PortController";
import * as TripController from "./TripController"; import * as TripController from "./TripController";
export { AuthController, DeviceController, MapController, TripController };
export {
AlarmController,
AuthController,
DeviceController,
FishController,
MapController,
PortController,
TripController,
};

View File

@@ -9,6 +9,20 @@ declare namespace Model {
token?: string; token?: string;
} }
interface ProfileResponse {
id?: string;
email?: string;
metadata?: ProfileMetadata;
}
interface ProfileMetadata {
frontend_thing_id?: string;
frontend_thing_key?: string;
full_name?: string;
phone_number?: string;
user_type?: string;
}
interface GPSResponse { interface GPSResponse {
lat: number; lat: number;
lon: number; lon: number;
@@ -17,41 +31,6 @@ declare namespace Model {
fishing: boolean; fishing: boolean;
t: number; t: number;
} }
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;
type: string;
}
interface TransformedEntity {
id: string;
value: number;
valueString: string;
time: number;
type: string;
}
// Banzones
// Banzone // Banzone
interface Zone { interface Zone {
id?: string; id?: string;
@@ -60,7 +39,9 @@ declare namespace Model {
conditions?: Condition[]; conditions?: Condition[];
enabled?: boolean; enabled?: boolean;
updated_at?: Date; updated_at?: Date;
geom?: Geom; geometry?: string;
description?: string;
province_code?: string;
} }
interface Condition { interface Condition {
@@ -93,7 +74,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;
@@ -300,4 +311,151 @@ declare namespace Model {
owner_id?: string; owner_id?: string;
description?: 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;
}
interface ShipBodyRequest {
name?: string;
reg_number?: string;
imo_number?: string;
mmsi_number?: string;
thing_id?: string;
ship_type?: number;
owner_id?: string;
home_port?: number;
ship_length?: number;
ship_power?: number;
ship_group_id?: string;
fishing_license_number?: string;
fishing_license_expiry_date?: Date;
}
interface ShipResponse {
ships?: Ship[];
}
interface Ship {
id?: string;
thing_id?: string;
owner_id?: string;
name?: string;
ship_type?: number;
home_port?: number;
ship_length?: number;
ship_power?: number;
reg_number?: string;
imo_number?: string;
mmsi_number?: string;
fishing_license_number?: string;
fishing_license_expiry_date?: Date;
province_code?: string;
ship_group_id?: string;
created_at?: Date;
updated_at?: Date;
}
interface PortResponse {
total?: number;
offset?: number;
limit?: number;
ports?: Port[];
}
interface Port {
id?: number;
name?: string;
type?: Type;
classification?: Classification;
position_point?: string;
has_origin_confirm?: boolean;
province_code?: string;
updated_at?: Date;
is_deleted?: boolean;
}
enum Classification {
ChưaXácĐịnh = "Chưa xác định",
I = "I",
Ii = "II",
}
enum Type {
Fishing = "fishing",
}
// Groups
interface GroupResponse {
total?: number;
level?: number;
name?: string;
groups?: Group[];
}
interface Group {
id?: string;
name?: string;
owner_id?: string;
description?: string;
metadata?: GroupMetadata;
level?: number;
path?: string;
children?: Child[];
created_at?: Date;
updated_at?: Date;
}
interface Child {
id?: string;
name?: string;
owner_id?: string;
parent_id?: string;
description?: string;
metadata?: GroupMetadata;
level?: number;
path?: string;
children?: Child[];
created_at?: Date;
updated_at?: Date;
}
interface GroupMetadata {
code?: string;
short_name?: string;
}
} }

View File

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

View File

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

19
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"base64-js": "^1.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
@@ -27,7 +28,6 @@
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7", "expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",
@@ -8545,23 +8545,6 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.10.tgz",
"integrity": "sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-keep-awake": { "node_modules/expo-keep-awake": {
"version": "15.0.7", "version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz",

View File

@@ -23,6 +23,7 @@
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"axios": "^1.13.1", "axios": "^1.13.1",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"base64-js": "^1.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "~54.0.20", "expo": "~54.0.20",
@@ -30,7 +31,6 @@
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.8",
"expo-localization": "~17.0.7", "expo-localization": "~17.0.7",
"expo-router": "~6.0.13", "expo-router": "~6.0.13",

View File

@@ -1,139 +1,20 @@
import { import {
AUTO_REFRESH_INTERVAL, AUTO_REFRESH_INTERVAL,
EVENT_ALARM_DATA,
EVENT_BANZONE_DATA, EVENT_BANZONE_DATA,
EVENT_ENTITY_DATA,
EVENT_GPS_DATA,
EVENT_SEARCH_THINGS, EVENT_SEARCH_THINGS,
EVENT_TRACK_POINTS_DATA,
} from "@/constants"; } from "@/constants";
import { import { querySearchThings } from "@/controller/DeviceController";
queryAlarm,
queryEntities,
queryGpsData,
querySearchThings,
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; searchThings: ReturnType<typeof setInterval> | null;
} = { } = {
gps: null,
alarm: null,
entities: null,
trackPoints: null,
banzones: null, banzones: null,
searchThings: 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 () => {
@@ -199,9 +80,5 @@ export function stopEvents() {
} }
export function startEvents() { export function startEvents() {
getGpsEventBus();
getAlarmEventBus();
getEntitiesEventBus();
getTrackPointsEventBus();
getBanzonesEventBus(); getBanzonesEventBus();
} }

View File

@@ -41,3 +41,8 @@ export function formatRelativeTime(unixTime: number): string {
if (diffMonths < 12) return `${diffMonths} tháng trước`; if (diffMonths < 12) return `${diffMonths} tháng trước`;
return `${diffYears} năm 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");
};

41
state/use-group.ts Normal file
View File

@@ -0,0 +1,41 @@
import {
queryChilrentOfGroups,
queryUserGroup,
} from "@/controller/GroupController";
import { create } from "zustand";
type Groups = {
groups: Model.GroupResponse | null;
childrenOfGroups?: Model.GroupResponse | null;
getChildrenOfGroups: (group_id: string) => Promise<void>;
getUserGroups: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useGroup = create<Groups>((set) => ({
groups: null,
childrenOfGroups: null,
getUserGroups: async () => {
try {
const response = await queryUserGroup();
set({ groups: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ groups: null });
}
},
getChildrenOfGroups: async (group_id: string) => {
try {
set({ loading: true });
const response = await queryChilrentOfGroups(group_id);
set({ childrenOfGroups: response.data, loading: false });
} catch (error) {
console.error("Error when fetching children of groups: ", error);
set({ error: "Failed to fetch children of groups", loading: false });
set({ childrenOfGroups: null });
}
},
error: null,
}));

32
state/use-ports.ts Normal file
View File

@@ -0,0 +1,32 @@
import { queryPorts } from "@/controller/PortController";
import { create } from "zustand";
type Ports = {
ports: Model.PortResponse | null;
getPorts: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const usePort = create<Ports>((set) => ({
ports: null,
getPorts: async (body?: Model.SearchThingBody) => {
try {
if (body === undefined) {
body = {
offset: 0,
limit: 50,
dir: "asc",
order: "id",
};
}
const response = await queryPorts(body);
set({ ports: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ ports: null });
}
},
error: null,
}));

24
state/use-ship-groups.ts Normal file
View File

@@ -0,0 +1,24 @@
import { queryShipGroups } from "@/controller/DeviceController";
import { create } from "zustand";
type ShipGroups = {
shipGroups: Model.ShipGroup[] | null;
getShipGroups: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useShipGroups = create<ShipGroups>((set) => ({
shipGroups: null,
getShipGroups: async () => {
try {
const response = await queryShipGroups();
set({ shipGroups: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Port: ", error);
set({ error: "Failed to fetch Port data", loading: false });
set({ shipGroups: null });
}
},
error: null,
}));

24
state/use-ship.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { queryAllShips } from "@/controller/DeviceController";
import { create } from "zustand";
type Ship = {
ships: Model.Ship[] | null;
getShip: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useShip = create<Ship>((set) => ({
ships: null,
getShip: async () => {
try {
const response = await queryAllShips({});
set({ ships: response.data?.ships, loading: false });
} catch (error) {
console.error("Error when fetch Ship: ", error);
set({ error: "Failed to fetch Ship data", loading: false });
set({ ships: null });
}
},
error: null,
}));

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

@@ -0,0 +1,31 @@
import { queryTripsList } from "@/controller/TripController";
import { create } from "zustand";
type TripsListState = {
tripsList: Model.TripsListResponse | null;
getTripsList: (body: Model.TripListBody) => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useTripsList = create<TripsListState>((set) => ({
tripsList: null,
getTripsList: async (body: Model.TripListBody) => {
set({ loading: true, error: null });
try {
const response = await queryTripsList(body);
console.log("Trip fetching API: ", response.data.trips?.length);
set({ tripsList: response.data ?? [], loading: false });
} catch (error) {
console.error("Error when fetch things: ", error);
set({
error: "Failed to fetch things data",
loading: false,
tripsList: null,
});
}
},
error: null,
loading: false,
}));

View File

@@ -1,3 +1,4 @@
import { ROLE, UID } from "@/constants";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
export async function setStorageItem( export async function setStorageItem(
@@ -28,3 +29,20 @@ export async function removeStorageItem(key: string): Promise<void> {
console.error("Error removing storage item:", error); console.error("Error removing storage item:", error);
} }
} }
export async function clearUserStorage() {
try {
await AsyncStorage.removeItem(UID);
await AsyncStorage.removeItem(ROLE);
} catch (error) {
console.error("Error with clear user Storage: ", error);
}
}
export async function addUserStorage(userId: string, role: string) {
try {
setStorageItem(UID, userId);
setStorageItem(ROLE, role);
} catch (error) {
console.error("Error with set user Storage: ", error);
}
}