Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35027a7e23 | ||
|
|
6af6749712 | ||
|
|
3e1c4dcbc5 | ||
|
|
df4318fed4 |
1494
ReactQuery_Axios.md
Normal file
1494
ReactQuery_Axios.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
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 (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }} edges={["top"]}>
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
<ThemedView style={styles.scrollContent}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.titleText}>Quản lý 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>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<View style={styles.contentWrapper}>
|
||||||
|
{selected === "ships" && <ShipsScreen />}
|
||||||
|
{selected === "devices" && <DevicesScreen />}
|
||||||
|
{selected === "fleets" && <FleetsScreen />}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ThemedView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (colors: typeof Colors.light, scheme: ColorScheme) =>
|
||||||
scrollContent: {
|
StyleSheet.create({
|
||||||
flexGrow: 1,
|
scrollContent: {
|
||||||
},
|
flexGrow: 1,
|
||||||
container: {
|
},
|
||||||
alignItems: "center",
|
container: {
|
||||||
padding: 15,
|
alignItems: "center",
|
||||||
},
|
flex: 1,
|
||||||
titleText: {
|
},
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: "700",
|
header: {
|
||||||
lineHeight: 40,
|
width: "100%",
|
||||||
marginBottom: 30,
|
paddingVertical: 8,
|
||||||
fontFamily: Platform.select({
|
paddingHorizontal: 4,
|
||||||
ios: "System",
|
},
|
||||||
android: "Roboto",
|
segmentContainer: {
|
||||||
default: "System",
|
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%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
|
||||||
// }),
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
@@ -1,302 +1,246 @@
|
|||||||
|
import { AlarmCard } from "@/components/alarm/AlarmCard";
|
||||||
|
import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
|
||||||
import { ThemedText } from "@/components/themed-text";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
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 { Ionicons } from "@expo/vector-icons";
|
||||||
import dayjs from "dayjs";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import {
|
||||||
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
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";
|
||||||
import { AlarmData } from ".";
|
|
||||||
|
|
||||||
// ============ Types ============
|
const PAGE_SIZE = 2;
|
||||||
type AlarmType = "approaching" | "entered" | "fishing";
|
|
||||||
|
|
||||||
interface AlarmCardProps {
|
const WarningScreen = () => {
|
||||||
alarm: AlarmData;
|
const [defaultAlarmParams, setDefaultAlarmParams] =
|
||||||
onPress?: () => void;
|
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));
|
||||||
|
|
||||||
// ============ Config ============
|
const { colors } = useThemeContext();
|
||||||
const ALARM_CONFIG: Record<
|
|
||||||
AlarmType,
|
|
||||||
{
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
label: string;
|
|
||||||
bgColor: string;
|
|
||||||
borderColor: string;
|
|
||||||
iconBgColor: string;
|
|
||||||
iconColor: string;
|
|
||||||
labelColor: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
entered: {
|
|
||||||
icon: "warning",
|
|
||||||
label: "Xâm nhập",
|
|
||||||
bgColor: "bg-red-50",
|
|
||||||
borderColor: "border-red-200",
|
|
||||||
iconBgColor: "bg-red-100",
|
|
||||||
iconColor: "#DC2626",
|
|
||||||
labelColor: "text-red-600",
|
|
||||||
},
|
|
||||||
approaching: {
|
|
||||||
icon: "alert-circle",
|
|
||||||
label: "Tiếp cận",
|
|
||||||
bgColor: "bg-amber-50",
|
|
||||||
borderColor: "border-amber-200",
|
|
||||||
iconBgColor: "bg-amber-100",
|
|
||||||
iconColor: "#D97706",
|
|
||||||
labelColor: "text-amber-600",
|
|
||||||
},
|
|
||||||
fishing: {
|
|
||||||
icon: "fish",
|
|
||||||
label: "Đánh bắt",
|
|
||||||
bgColor: "bg-orange-50",
|
|
||||||
borderColor: "border-orange-200",
|
|
||||||
iconBgColor: "bg-orange-100",
|
|
||||||
iconColor: "#EA580C",
|
|
||||||
labelColor: "text-orange-600",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ Helper Functions ============
|
const hasFilters = useMemo(() => {
|
||||||
const formatTimestamp = (timestamp?: number): string => {
|
return Boolean(
|
||||||
if (!timestamp) return "N/A";
|
(defaultAlarmParams as any)?.name ||
|
||||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
((defaultAlarmParams as any)?.level !== undefined &&
|
||||||
};
|
(defaultAlarmParams as any).level !== 0) ||
|
||||||
|
(defaultAlarmParams as any)?.confirmed !== undefined
|
||||||
|
);
|
||||||
|
}, [defaultAlarmParams]);
|
||||||
|
|
||||||
// ============ AlarmCard Component ============
|
useEffect(() => {
|
||||||
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
getAlarmsData(0, false);
|
||||||
const config = ALARM_CONFIG[alarm.type];
|
}, []);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<TouchableOpacity
|
if (isShowSearchForm) {
|
||||||
onPress={onPress}
|
// Reset opacity to 0, then animate to 1
|
||||||
activeOpacity={0.7}
|
formOpacity.setValue(0);
|
||||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
Animated.timing(formOpacity, {
|
||||||
>
|
toValue: 1,
|
||||||
<View className="flex-row items-start gap-3">
|
duration: 300,
|
||||||
{/* Icon Container */}
|
useNativeDriver: true,
|
||||||
<View
|
}).start();
|
||||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
} else {
|
||||||
>
|
formOpacity.setValue(0);
|
||||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
}
|
||||||
</View>
|
}, [isShowSearchForm, formOpacity]);
|
||||||
|
|
||||||
{/* Content */}
|
const getAlarmsData = async (
|
||||||
<View className="flex-1">
|
nextOffset = 0,
|
||||||
{/* Header: Ship name + Badge */}
|
append = false,
|
||||||
<View className="flex-row items-center justify-between mb-1">
|
paramsOverride?: Model.AlarmPayload
|
||||||
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
) => {
|
||||||
{alarm.ship_name || alarm.thing_id}
|
try {
|
||||||
</ThemedText>
|
if (append) setIsLoadingMore(true);
|
||||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
else setLoading(true);
|
||||||
<ThemedText
|
// console.log("Call alarm with offset: ", nextOffset);
|
||||||
className={`text-xs font-semibold ${config.labelColor}`}
|
const usedParams = paramsOverride ?? defaultAlarmParams;
|
||||||
>
|
// console.log("params: ", usedParams);
|
||||||
{config.label}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Zone Info */}
|
const resp = await queryAlarms({
|
||||||
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
...usedParams,
|
||||||
{alarm.zone.message || alarm.zone.zone_name}
|
offset: nextOffset,
|
||||||
</ThemedText>
|
});
|
||||||
|
const slice = resp.data?.alarms ?? [];
|
||||||
|
|
||||||
{/* Footer: Zone ID + Time */}
|
setAlarms((prev) => (append ? [...prev, ...slice] : slice));
|
||||||
<View className="flex-row items-center justify-between">
|
setOffset(nextOffset);
|
||||||
<View className="flex-row items-center gap-1">
|
setHasMore(nextOffset + PAGE_SIZE < resp.data?.total!);
|
||||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
} catch (error) {
|
||||||
<ThemedText className="text-xs text-gray-500">
|
console.error("Cannot get Alarm Data: ", error);
|
||||||
{formatTimestamp(alarm.zone.gps_time)}
|
} finally {
|
||||||
</ThemedText>
|
setLoading(false);
|
||||||
</View>
|
setIsLoadingMore(false);
|
||||||
</View>
|
setRefreshing(false);
|
||||||
</View>
|
}
|
||||||
</View>
|
};
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
AlarmCard.displayName = "AlarmCard";
|
const handleAlarmReload = useCallback((onReload: boolean) => {
|
||||||
|
if (onReload) {
|
||||||
// ============ Main Component ============
|
getAlarmsData(0, false, undefined);
|
||||||
interface WarningScreenProps {
|
}
|
||||||
alarms?: AlarmData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
|
||||||
// Mock data for demo - replace with actual props
|
|
||||||
const sampleAlarms: AlarmData[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
thing_id: "SHIP-001",
|
|
||||||
ship_name: "Ocean Star",
|
|
||||||
type: "entered",
|
|
||||||
zone: {
|
|
||||||
zone_type: 1,
|
|
||||||
zone_name: "Khu vực cấm A1",
|
|
||||||
zone_id: "A1",
|
|
||||||
message: "Tàu đã đi vào vùng cấm A1",
|
|
||||||
alarm_type: 1,
|
|
||||||
lat: 10.12345,
|
|
||||||
lon: 106.12345,
|
|
||||||
s: 12,
|
|
||||||
h: 180,
|
|
||||||
fishing: false,
|
|
||||||
gps_time: 1733389200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
thing_id: "SHIP-002",
|
|
||||||
ship_name: "Blue Whale",
|
|
||||||
type: "approaching",
|
|
||||||
zone: {
|
|
||||||
zone_type: 2,
|
|
||||||
zone_name: "Vùng cảnh báo B3",
|
|
||||||
zone_id: "B3",
|
|
||||||
message: "Tàu đang tiếp cận khu vực cấm B3",
|
|
||||||
alarm_type: 2,
|
|
||||||
lat: 9.87654,
|
|
||||||
lon: 105.87654,
|
|
||||||
gps_time: 1733389260,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
thing_id: "SHIP-003",
|
|
||||||
ship_name: "Sea Dragon",
|
|
||||||
type: "fishing",
|
|
||||||
zone: {
|
|
||||||
zone_type: 3,
|
|
||||||
zone_name: "Vùng cấm đánh bắt C2",
|
|
||||||
zone_id: "C2",
|
|
||||||
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
|
||||||
alarm_type: 3,
|
|
||||||
lat: 11.11223,
|
|
||||||
lon: 107.44556,
|
|
||||||
fishing: true,
|
|
||||||
gps_time: 1733389320,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
thing_id: "SHIP-004",
|
|
||||||
ship_name: "Red Coral",
|
|
||||||
type: "entered",
|
|
||||||
zone: {
|
|
||||||
zone_type: 1,
|
|
||||||
zone_name: "Khu vực A2",
|
|
||||||
zone_id: "A2",
|
|
||||||
message: "Tàu đã đi sâu vào khu vực A2",
|
|
||||||
alarm_type: 1,
|
|
||||||
gps_time: 1733389380,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
thing_id: "SHIP-005",
|
|
||||||
ship_name: "Silver Wind",
|
|
||||||
type: "approaching",
|
|
||||||
zone: {
|
|
||||||
zone_type: 2,
|
|
||||||
zone_name: "Vùng B1",
|
|
||||||
zone_id: "B1",
|
|
||||||
message: "Tàu đang tiến gần vào vùng B1",
|
|
||||||
alarm_type: 2,
|
|
||||||
gps_time: 1733389440,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
|
||||||
|
|
||||||
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
|
||||||
console.log("Alarm pressed:", alarm);
|
|
||||||
// TODO: Navigate to alarm detail or show modal
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderAlarmCard = useCallback(
|
const renderAlarmCard = useCallback(
|
||||||
({ item }: { item: AlarmData }) => (
|
({ item }: { item: Model.Alarm }) => (
|
||||||
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
<AlarmCard alarm={item} onReload={handleAlarmReload} />
|
||||||
),
|
),
|
||||||
[handleAlarmPress]
|
[handleAlarmReload]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback(
|
||||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
(item: Model.Alarm, index: number) =>
|
||||||
|
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
const nextOffset = offset + PAGE_SIZE;
|
||||||
|
getAlarmsData(nextOffset, true);
|
||||||
|
}, [isLoadingMore, hasMore, offset]);
|
||||||
|
|
||||||
// Count alarms by type
|
const handleRefresh = useCallback(() => {
|
||||||
const alarmCounts = useMemo(() => {
|
setRefreshing(true);
|
||||||
return displayAlarms.reduce((acc, alarm) => {
|
getAlarmsData(0, false, undefined);
|
||||||
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
}, []);
|
||||||
return acc;
|
|
||||||
}, {} as Record<AlarmType, number>);
|
const onSearch = useCallback(
|
||||||
}, [displayAlarms]);
|
(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={styles.container} edges={["top"]}>
|
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||||
<ThemedView style={styles.content}>
|
<ThemedView style={styles.content}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View className="flex-row items-center gap-3">
|
<View style={styles.headerLeft}>
|
||||||
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
|
||||||
<Ionicons name="warning" size={22} color="#fff" />
|
|
||||||
</View>
|
|
||||||
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
<View className="bg-red-500 px-3 py-1 rounded-full">
|
<View style={styles.badgeContainer}>
|
||||||
<ThemedText className="text-white text-sm font-semibold">
|
<TouchableOpacity onPress={toggleSearchForm}>
|
||||||
{displayAlarms.length}
|
<Ionicons
|
||||||
</ThemedText>
|
size={20}
|
||||||
|
name="filter-outline"
|
||||||
|
color={hasFilters ? colors.primary : colors.text}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Stats Bar */}
|
{/* Search Form */}
|
||||||
<View className="flex-row px-4 pb-3 gap-2">
|
{isShowSearchForm && (
|
||||||
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
<Animated.View style={{ opacity: formOpacity, zIndex: 100 }}>
|
||||||
(type) => {
|
<AlarmSearchForm
|
||||||
const config = ALARM_CONFIG[type];
|
initialValue={{
|
||||||
const count = alarmCounts[type] || 0;
|
name: defaultAlarmParams.name || "",
|
||||||
return (
|
level: defaultAlarmParams.level || 0,
|
||||||
<View
|
confirmed: defaultAlarmParams.confirmed,
|
||||||
key={type}
|
}}
|
||||||
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
onSubmit={onSearch}
|
||||||
>
|
onReset={toggleSearchForm}
|
||||||
<Ionicons
|
/>
|
||||||
name={config.icon}
|
</Animated.View>
|
||||||
size={14}
|
)}
|
||||||
color={config.iconColor}
|
|
||||||
/>
|
|
||||||
<ThemedText
|
|
||||||
className={`text-xs font-medium ${config.labelColor}`}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</ThemedText>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Alarm List */}
|
{/* Alarm List */}
|
||||||
<FlatList
|
{alarms.length > 0 ? (
|
||||||
data={displayAlarms}
|
<FlatList
|
||||||
renderItem={renderAlarmCard}
|
data={alarms}
|
||||||
keyExtractor={keyExtractor}
|
renderItem={renderAlarmCard}
|
||||||
ItemSeparatorComponent={ItemSeparator}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
initialNumToRender={10}
|
onEndReached={handleLoadMore}
|
||||||
maxToRenderPerBatch={10}
|
onEndReachedThreshold={0.5}
|
||||||
windowSize={5}
|
refreshing={refreshing}
|
||||||
/>
|
onRefresh={handleRefresh}
|
||||||
|
ListFooterComponent={
|
||||||
|
isLoadingMore ? (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<ActivityIndicator size="small" color="#dc2626" />
|
||||||
|
<ThemedText style={styles.footerText}>Đang tải...</ThemedText>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="shield-checkmark" size={48} color="#16a34a" />
|
||||||
|
<ThemedText style={styles.emptyText}>
|
||||||
|
Không có cảnh báo nào
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default WarningScreen;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
@@ -311,13 +255,60 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 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: 26,
|
fontSize: 24,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
|
badgeContainer: {
|
||||||
|
// backgroundColor: "#dc2626",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
listContent: {
|
listContent: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: 20,
|
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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
interface ButtonCancelTripProps {
|
|
||||||
title?: string;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
|
||||||
title,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.button}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.text}>{displayTitle}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 2,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2, // cho Android
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonCancelTrip;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { queryGpsData } from "@/controller/DeviceController";
|
|
||||||
import {
|
|
||||||
queryStartNewHaul,
|
|
||||||
queryUpdateTripState,
|
|
||||||
} from "@/controller/TripController";
|
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import {
|
|
||||||
showErrorToast,
|
|
||||||
showSuccessToast,
|
|
||||||
showWarningToast,
|
|
||||||
} from "@/services/toast_service";
|
|
||||||
import { useTrip } from "@/state/use-trip";
|
|
||||||
import { AntDesign } from "@expo/vector-icons";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Alert, StyleSheet, View } from "react-native";
|
|
||||||
import IconButton from "./IconButton";
|
|
||||||
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
|
||||||
|
|
||||||
interface StartButtonProps {
|
|
||||||
gpsData?: Model.GPSResponse;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface a {
|
|
||||||
fishingLogs?: Model.FishingLogInfo[] | null;
|
|
||||||
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
|
||||||
isEditing?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
|
||||||
gpsData,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const [isStarted, setIsStarted] = useState(false);
|
|
||||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const { trip, getTrip } = useTrip();
|
|
||||||
useEffect(() => {
|
|
||||||
getTrip();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkHaulFinished = () => {
|
|
||||||
return trip?.fishing_logs?.some((h) => h.status === 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
if (isStarted) {
|
|
||||||
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
|
||||||
{
|
|
||||||
text: t("trip.cancelButton"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("trip.endButton"),
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(false);
|
|
||||||
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
|
||||||
{
|
|
||||||
text: t("trip.cancelButton"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("trip.startButton"),
|
|
||||||
onPress: () => {
|
|
||||||
setIsStarted(true);
|
|
||||||
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onPress) {
|
|
||||||
onPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartTrip = async (state: number, note?: string) => {
|
|
||||||
if (trip?.trip_status !== 2) {
|
|
||||||
showWarningToast(t("trip.alreadyStarted"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const resp = await queryUpdateTripState({
|
|
||||||
status: state,
|
|
||||||
note: note || "",
|
|
||||||
});
|
|
||||||
if (resp.status === 200) {
|
|
||||||
showSuccessToast(t("trip.startTripSuccess"));
|
|
||||||
await getTrip();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error stating trip :", error);
|
|
||||||
showErrorToast("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewHaul = async () => {
|
|
||||||
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
|
||||||
showWarningToast(t("trip.finishCurrentHaul"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!gpsData) {
|
|
||||||
const response = await queryGpsData();
|
|
||||||
gpsData = response.data;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body: Model.NewFishingLogRequest = {
|
|
||||||
trip_id: trip?.id || "",
|
|
||||||
start_at: new Date(),
|
|
||||||
start_lat: gpsData.lat,
|
|
||||||
start_lon: gpsData.lon,
|
|
||||||
weather_description: t("trip.weatherDescription"),
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await queryStartNewHaul(body);
|
|
||||||
if (resp.status === 200) {
|
|
||||||
showSuccessToast(t("trip.startHaulSuccess"));
|
|
||||||
await getTrip();
|
|
||||||
} else {
|
|
||||||
showErrorToast(t("trip.createHaulFailed"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
// showErrorToast(t("trip.createHaulFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
|
||||||
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{trip?.trip_status === 2 ? (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" />}
|
|
||||||
type="primary"
|
|
||||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
|
||||||
onPress={async () => handleStartTrip(3)}
|
|
||||||
>
|
|
||||||
{t("trip.startTrip")}
|
|
||||||
</IconButton>
|
|
||||||
) : checkHaulFinished() ? (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" color={"white"} />}
|
|
||||||
type="primary"
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
|
||||||
>
|
|
||||||
{t("trip.endHaul")}
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
|
||||||
icon={<AntDesign name="plus" color={"white"} />}
|
|
||||||
type="primary"
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onPress={async () => {
|
|
||||||
createNewHaul();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("trip.startHaul")}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
<CreateOrUpdateHaulModal
|
|
||||||
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
|
||||||
fishingLogIndex={trip?.fishing_logs?.length!}
|
|
||||||
isVisible={isFinishHaulModalOpen}
|
|
||||||
onClose={function (): void {
|
|
||||||
setIsFinishHaulModalOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#4ecdc4", // màu ngọc lam
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.15,
|
|
||||||
shadowRadius: 3,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
elevation: 3, // hiệu ứng nổi trên Android
|
|
||||||
},
|
|
||||||
buttonActive: {
|
|
||||||
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
marginRight: 6,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonCreateNewHaulOrTrip;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useI18n } from "@/hooks/use-i18n";
|
|
||||||
import React from "react";
|
|
||||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
||||||
|
|
||||||
interface ButtonEndTripProps {
|
|
||||||
title?: string;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const displayTitle = title || t("trip.buttonEndTrip.title");
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.button}
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
<Text style={styles.text}>{displayTitle}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#ed9434", // màu cam sáng
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 28,
|
|
||||||
alignSelf: "flex-start",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 3,
|
|
||||||
shadowOffset: { width: 0, height: 1 },
|
|
||||||
elevation: 2, // hiệu ứng nổi trên Android
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ButtonEndTrip;
|
|
||||||
@@ -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(() => {
|
||||||
getShipGroups();
|
if (shipGroups === null) {
|
||||||
}, []);
|
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={
|
||||||
label: group.name || "",
|
shipGroups?.map((group) => ({
|
||||||
value: group.id || "",
|
label: group.name || "",
|
||||||
}))}
|
value: group.id || "",
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
placeholder="Chọn đội tàu"
|
placeholder="Chọn đội tàu"
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
439
components/alarm/AlarmCard.tsx
Normal file
439
components/alarm/AlarmCard.tsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import {
|
||||||
|
queryConfirmAlarm,
|
||||||
|
queryrUnconfirmAlarm,
|
||||||
|
} from "@/controller/AlarmController";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface AlarmCardProps {
|
||||||
|
alarm: Model.Alarm;
|
||||||
|
onReload?: (onReload: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const canSubmit = useMemo(
|
||||||
|
() => note.trim().length > 0 || alarm.confirmed,
|
||||||
|
[note, alarm.confirmed]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine level and colors based on alarm level
|
||||||
|
const getAlarmConfig = (level?: number) => {
|
||||||
|
if (level === 3) {
|
||||||
|
// Danger - Red
|
||||||
|
return {
|
||||||
|
level: 3,
|
||||||
|
icon: "warning" as const,
|
||||||
|
bgColor: "#fee2e2",
|
||||||
|
borderColor: "#DC0E0E",
|
||||||
|
iconColor: "#dc2626",
|
||||||
|
statusBg: "#dcfce7",
|
||||||
|
statusText: "#166534",
|
||||||
|
};
|
||||||
|
} else if (level === 2) {
|
||||||
|
// Caution - Yellow/Orange
|
||||||
|
return {
|
||||||
|
level: 2,
|
||||||
|
icon: "alert-circle" as const,
|
||||||
|
bgColor: "#fef3c7",
|
||||||
|
borderColor: "#FF6C0C",
|
||||||
|
iconColor: "#d97706",
|
||||||
|
statusBg: "#fef08a",
|
||||||
|
statusText: "#713f12",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Info - Green
|
||||||
|
return {
|
||||||
|
level: 1,
|
||||||
|
icon: "information-circle" as const,
|
||||||
|
bgColor: "#fffefe",
|
||||||
|
borderColor: "#FF937E",
|
||||||
|
iconColor: "#FF937E",
|
||||||
|
statusBg: "#dcfce7",
|
||||||
|
statusText: "#166534",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getAlarmConfig(alarm.level);
|
||||||
|
|
||||||
|
const formatDate = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePayload = () => {
|
||||||
|
if (!alarm.id || !alarm.thing_id || !alarm.time) {
|
||||||
|
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfirm = async (action: "confirm" | "unconfirm") => {
|
||||||
|
if (!ensurePayload()) return;
|
||||||
|
if (action === "confirm" && note.trim().length === 0) {
|
||||||
|
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
if (action === "confirm") {
|
||||||
|
await queryConfirmAlarm({
|
||||||
|
id: alarm.id!,
|
||||||
|
thing_id: alarm.thing_id!,
|
||||||
|
time: alarm.time!,
|
||||||
|
description: note.trim(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await queryrUnconfirmAlarm({
|
||||||
|
id: alarm.id!,
|
||||||
|
thing_id: alarm.thing_id!,
|
||||||
|
time: alarm.time!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onReload?.(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Cannot confirm/unconfirm alarm: ", error);
|
||||||
|
const status = error?.response?.status ?? error?.status;
|
||||||
|
// If server returns 404, ignore silently
|
||||||
|
if (status !== 404) {
|
||||||
|
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setShowModal(false);
|
||||||
|
setNote("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = (alarm: Model.Alarm) => {
|
||||||
|
if (alarm.confirmed) {
|
||||||
|
Alert.alert(
|
||||||
|
"Thông báo",
|
||||||
|
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
|
||||||
|
[
|
||||||
|
{ text: "Hủy", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Ngừng xác nhận",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => submitConfirm("unconfirm"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: config.bgColor,
|
||||||
|
borderLeftColor: config.borderColor,
|
||||||
|
borderLeftWidth: 5,
|
||||||
|
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Left Side - Icon and Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Icon */}
|
||||||
|
<View
|
||||||
|
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
|
||||||
|
>
|
||||||
|
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title and Info */}
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
{/* Name */}
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text
|
||||||
|
style={[styles.title, { color: colors.text }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{alarm.name || alarm.thing_name || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Location (thing_name) and Time */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||||
|
>
|
||||||
|
Trạm
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoValue, { color: colors.text }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{alarm.thing_name || "Unknown"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoItem}>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||||
|
>
|
||||||
|
Thời gian
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[styles.infoValue, { color: colors.text }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{formatDate(alarm.time)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.statusContainer}
|
||||||
|
onPress={() => handlePress(alarm)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{
|
||||||
|
backgroundColor: alarm.confirmed ? "#8FD14F" : "#EEEEEE",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: alarm.confirmed ? "#166534" : "black" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{alarm.confirmed && (
|
||||||
|
<View style={styles.rightIcon}>
|
||||||
|
<Ionicons
|
||||||
|
name="checkmark-done"
|
||||||
|
size={20}
|
||||||
|
color={alarm.confirmed ? "#78C841" : config.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.modalContent,
|
||||||
|
{ backgroundColor: colors.background },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||||
|
Nhập ghi chú xác nhận
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.text }]}
|
||||||
|
placeholder="Nhập ghi chú"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
multiline
|
||||||
|
value={note}
|
||||||
|
onChangeText={setNote}
|
||||||
|
editable={!submitting}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalButton, styles.cancelButton]}
|
||||||
|
onPress={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelText}>Hủy</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.modalButton,
|
||||||
|
styles.confirmButton,
|
||||||
|
!canSubmit && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={() => submitConfirm("confirm")}
|
||||||
|
disabled={submitting || !canSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator color="#fff" size="small" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmText}>Xác nhận</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
borderRadius: 12,
|
||||||
|
// borderWidth: 1,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
// marginRight: 5,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
infoItem: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 0.2,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
rightIcon: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#e5e7eb",
|
||||||
|
padding: 12,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalButton: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "#e5e7eb",
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
color: "#111827",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
confirmText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AlarmCard;
|
||||||
305
components/alarm/AlarmSearchForm.tsx
Normal file
305
components/alarm/AlarmSearchForm.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import Select, { SelectOption } from "@/components/Select";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
interface AlarmSearchFormProps {
|
||||||
|
initialValue?: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
};
|
||||||
|
onSubmit: (payload: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
confirmed: string; // Using string for Select component compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
|
||||||
|
initialValue,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
}) => {
|
||||||
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
|
const levelOptions: SelectOption[] = [
|
||||||
|
{ label: "Tất cả", value: 0 },
|
||||||
|
{ label: "Cảnh báo", value: 1 },
|
||||||
|
{ label: "Nguy hiểm", value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const confirmedOptions: SelectOption[] = [
|
||||||
|
{ label: "Tất cả", value: "" },
|
||||||
|
{ label: "Đã xác nhận", value: "true" },
|
||||||
|
{ label: "Chưa xác nhận", value: "false" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset } = useForm<FormData>({
|
||||||
|
defaultValues: {
|
||||||
|
name: initialValue?.name || "",
|
||||||
|
level: initialValue?.level || 0,
|
||||||
|
confirmed:
|
||||||
|
initialValue?.confirmed !== undefined
|
||||||
|
? initialValue.confirmed.toString()
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValue) {
|
||||||
|
reset({
|
||||||
|
name: initialValue.name || "",
|
||||||
|
level: initialValue.level || 0,
|
||||||
|
confirmed:
|
||||||
|
initialValue.confirmed !== undefined
|
||||||
|
? initialValue.confirmed.toString()
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialValue, reset]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: FormData) => {
|
||||||
|
const payload: {
|
||||||
|
name?: string;
|
||||||
|
level?: number;
|
||||||
|
confirmed?: boolean;
|
||||||
|
} = {
|
||||||
|
...(data.name && { name: data.name }),
|
||||||
|
...(data.level !== 0 && { level: data.level }),
|
||||||
|
...(data.confirmed !== "" && {
|
||||||
|
confirmed: data.confirmed === "true",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
level: 0,
|
||||||
|
confirmed: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit empty payload to reset filters
|
||||||
|
onSubmit({});
|
||||||
|
onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
height: "auto",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Search Input */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field: { onChange, onBlur, value } }) => (
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
|
||||||
|
<View
|
||||||
|
style={[styles.inputWrapper, { borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.text }]}
|
||||||
|
placeholder="Tìm theo tên cảnh báo"
|
||||||
|
placeholderTextColor={colors.textSecondary}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onChange("")}
|
||||||
|
style={styles.clearButton}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="close-circle"
|
||||||
|
size={20}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Level and Confirmed Selects */}
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.halfWidth}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="level"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.selectContainer}>
|
||||||
|
<ThemedText style={styles.label}>Mức độ</ThemedText>
|
||||||
|
<Select
|
||||||
|
placeholder="Chọn mức độ"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={levelOptions}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.halfWidth}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="confirmed"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<View style={styles.selectContainer}>
|
||||||
|
<ThemedText style={styles.label}>Trạng thái</ThemedText>
|
||||||
|
<Select
|
||||||
|
placeholder="Chọn trạng thái"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={confirmedOptions}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.secondaryButton,
|
||||||
|
{
|
||||||
|
backgroundColor: colors.backgroundSecondary,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={handleReset}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
|
||||||
|
Đặt lại
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
styles.primaryButton,
|
||||||
|
{ backgroundColor: colors.primary },
|
||||||
|
]}
|
||||||
|
onPress={handleSubmit(onFormSubmit)}
|
||||||
|
>
|
||||||
|
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
|
||||||
|
Tìm kiếm
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
marginLeft: 8,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 16,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
halfWidth: {
|
||||||
|
width: "48%",
|
||||||
|
zIndex: 5000,
|
||||||
|
},
|
||||||
|
selectContainer: {
|
||||||
|
// flex: 1, // Remove this to prevent taking full width
|
||||||
|
zIndex: 5000,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
// backgroundColor is set dynamically
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AlarmSearchForm;
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export type AlarmStatus = "confirmed" | "pending";
|
|
||||||
|
|
||||||
export interface AlarmListItem {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
title: string;
|
|
||||||
station: string;
|
|
||||||
timestamp: number;
|
|
||||||
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
|
|
||||||
status: AlarmStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AlarmProp = {
|
|
||||||
alarmsData: AlarmListItem[];
|
|
||||||
onPress?: (alarm: AlarmListItem) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
data={alarmsData}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
|
|
||||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type AlarmCardProps = {
|
|
||||||
alarm: AlarmListItem;
|
|
||||||
onPress?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
|
||||||
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
|
|
||||||
alarm.level
|
|
||||||
);
|
|
||||||
const statusConfig = getStatusConfig(alarm.status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
|
|
||||||
>
|
|
||||||
<View className="flex-row justify-between items-start">
|
|
||||||
{/* Left content */}
|
|
||||||
<View className="flex-row flex-1">
|
|
||||||
{/* Icon */}
|
|
||||||
<View
|
|
||||||
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={getIconByLevel(alarm.level)}
|
|
||||||
size={20}
|
|
||||||
color={iconColor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View className="flex-1">
|
|
||||||
{/* Code */}
|
|
||||||
<Text
|
|
||||||
className={`text-xs font-medium mb-1 ${getCodeTextColor(
|
|
||||||
alarm.level
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{alarm.code}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<Text className="text-base font-semibold text-gray-800 mb-2">
|
|
||||||
{alarm.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Station and Time */}
|
|
||||||
<View className="flex-row">
|
|
||||||
<View className="mr-6">
|
|
||||||
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
|
|
||||||
<Text className="text-sm text-gray-600">{alarm.station}</Text>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
|
|
||||||
<Text className="text-sm text-gray-600">
|
|
||||||
{formatTimestamp(alarm.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
{/* <View className="mt-3">
|
|
||||||
<View
|
|
||||||
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-xs font-medium ${statusConfig.textColor}`}
|
|
||||||
>
|
|
||||||
{statusConfig.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Checkmark for confirmed */}
|
|
||||||
{/* {alarm.status === "confirmed" && (
|
|
||||||
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
|
|
||||||
<Ionicons name="checkmark" size={16} color="white" />
|
|
||||||
</View>
|
|
||||||
)} */}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColorsByLevel = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 3: // Danger - Red
|
|
||||||
return {
|
|
||||||
bgColor: "bg-red-50",
|
|
||||||
borderColor: "border-red-200",
|
|
||||||
iconColor: "#DC2626",
|
|
||||||
iconBgColor: "bg-red-100",
|
|
||||||
};
|
|
||||||
case 2: // Caution - Yellow/Orange
|
|
||||||
return {
|
|
||||||
bgColor: "bg-yellow-50",
|
|
||||||
borderColor: "border-yellow-200",
|
|
||||||
iconColor: "#CA8A04",
|
|
||||||
iconBgColor: "bg-yellow-100",
|
|
||||||
};
|
|
||||||
case 1: // Info - Green
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
bgColor: "bg-green-50",
|
|
||||||
borderColor: "border-green-200",
|
|
||||||
iconColor: "#16A34A",
|
|
||||||
iconBgColor: "bg-green-100",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
|
|
||||||
switch (level) {
|
|
||||||
case 3:
|
|
||||||
return "warning";
|
|
||||||
case 2:
|
|
||||||
return "alert-circle";
|
|
||||||
case 1:
|
|
||||||
default:
|
|
||||||
return "checkmark-circle";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCodeTextColor = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 3:
|
|
||||||
return "text-red-600";
|
|
||||||
case 2:
|
|
||||||
return "text-yellow-600";
|
|
||||||
case 1:
|
|
||||||
default:
|
|
||||||
return "text-green-600";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusConfig = (status: AlarmStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case "confirmed":
|
|
||||||
return {
|
|
||||||
label: "Đã xác nhận",
|
|
||||||
bgColor: "bg-green-100",
|
|
||||||
textColor: "text-green-700",
|
|
||||||
};
|
|
||||||
case "pending":
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: "Chờ xác nhận",
|
|
||||||
bgColor: "bg-yellow-100",
|
|
||||||
textColor: "text-yellow-700",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: number) => {
|
|
||||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlarmList;
|
|
||||||
16
components/manager/devices.tsx
Normal file
16
components/manager/devices.tsx
Normal 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 lý thiết bị</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
});
|
||||||
17
components/manager/fleets.tsx
Normal file
17
components/manager/fleets.tsx
Normal 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 lý đội tàu</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
});
|
||||||
847
components/manager/ship_components/CreateOrUpdateShip.tsx
Normal file
847
components/manager/ship_components/CreateOrUpdateShip.tsx
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
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]);
|
||||||
|
|
||||||
|
// 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 ký *</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 ký đỗ *
|
||||||
|
</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 (mã 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;
|
||||||
522
components/manager/ship_components/ShipCard.tsx
Normal file
522
components/manager/ship_components/ShipCard.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
140
components/manager/ships.tsx
Normal file
140
components/manager/ships.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { ThemedView } from "@/components/themed-view";
|
||||||
|
import { 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 handleUpdateShip = async (body: Model.ShipBodyRequest) => {
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (isCreateShip) {
|
||||||
|
// TODO: Thêm API create ship khi có
|
||||||
|
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={handleUpdateShip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
@@ -72,7 +72,11 @@ api.interceptors.response.use(
|
|||||||
statusText ||
|
statusText ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
|
|
||||||
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
// Không hiển thị toast cho status 400 (validation errors)
|
||||||
|
if (status !== 400) {
|
||||||
|
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
handle401();
|
handle401();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +38,7 @@ 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";
|
||||||
@@ -55,3 +58,8 @@ 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_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";
|
||||||
|
|||||||
15
controller/AlarmController.ts
Normal file
15
controller/AlarmController.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_ALARM, API_MANAGER_ALARM } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryAlarms(payload: Model.AlarmPayload) {
|
||||||
|
return await api.get<Model.AlarmResponse>(API_GET_ALARM, {
|
||||||
|
params: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryConfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||||
|
return await api.post(API_MANAGER_ALARM, body);
|
||||||
|
}
|
||||||
|
export async function queryrUnconfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||||
|
return await api.delete(API_MANAGER_ALARM, { data: body });
|
||||||
|
}
|
||||||
@@ -1,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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,22 @@ 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 queryUpdateShip(
|
||||||
|
shipId: string,
|
||||||
|
body: Model.ShipBodyRequest
|
||||||
|
) {
|
||||||
|
return await api.put(`${API_GET_ALL_SHIP}/${shipId}`, body);
|
||||||
|
}
|
||||||
|
|||||||
25
controller/GroupController.ts
Normal file
25
controller/GroupController.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
6
controller/PortController.ts
Normal file
6
controller/PortController.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
196
controller/typings.d.ts
vendored
196
controller/typings.d.ts
vendored
@@ -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;
|
||||||
@@ -332,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
41
state/use-group.ts
Normal file
41
state/use-group.ts
Normal 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
32
state/use-ports.ts
Normal 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
24
state/use-ship-groups.ts
Normal 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
24
state/use-ship.tsx
Normal 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,
|
||||||
|
}));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user