cập nhật modal CRUD thuyền viên trong trip
This commit is contained in:
@@ -13,7 +13,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { queryTripCrew } from "@/controller/TripCrewController";
|
import { queryTripCrew, deleteTripCrew } from "@/controller/TripCrewController";
|
||||||
import CrewList from "@/components/diary/TripCrewModal/CrewList";
|
import CrewList from "@/components/diary/TripCrewModal/CrewList";
|
||||||
import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal";
|
import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal";
|
||||||
|
|
||||||
@@ -21,7 +21,10 @@ export default function TripCrewPage() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { colors } = useThemeContext();
|
const { colors } = useThemeContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { tripId, tripName } = useLocalSearchParams<{ tripId: string; tripName?: string }>();
|
const { tripId, tripName } = useLocalSearchParams<{
|
||||||
|
tripId: string;
|
||||||
|
tripName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -85,9 +88,16 @@ export default function TripCrewPage() {
|
|||||||
text: t("common.delete"),
|
text: t("common.delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
// TODO: Call delete API when available
|
try {
|
||||||
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
|
// Call delete API
|
||||||
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
|
await deleteTripCrew(tripId, crew.PersonalID || "");
|
||||||
|
// Reload list
|
||||||
|
await fetchCrewData();
|
||||||
|
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting crew:", error);
|
||||||
|
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -102,16 +112,25 @@ export default function TripCrewPage() {
|
|||||||
|
|
||||||
const themedStyles = {
|
const themedStyles = {
|
||||||
container: { backgroundColor: colors.background },
|
container: { backgroundColor: colors.background },
|
||||||
header: { backgroundColor: colors.card, borderBottomColor: colors.separator },
|
header: {
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderBottomColor: colors.separator,
|
||||||
|
},
|
||||||
title: { color: colors.text },
|
title: { color: colors.text },
|
||||||
subtitle: { color: colors.textSecondary },
|
subtitle: { color: colors.textSecondary },
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, themedStyles.container]} edges={["top"]}>
|
<SafeAreaView
|
||||||
|
style={[styles.container, themedStyles.container]}
|
||||||
|
edges={["top"]}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={[styles.header, themedStyles.header]}>
|
<View style={[styles.header, themedStyles.header]}>
|
||||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
<TouchableOpacity
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={styles.backButton}
|
||||||
|
>
|
||||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.headerTitles}>
|
<View style={styles.headerTitles}>
|
||||||
@@ -119,7 +138,10 @@ export default function TripCrewPage() {
|
|||||||
{t("diary.crew.title")}
|
{t("diary.crew.title")}
|
||||||
</Text>
|
</Text>
|
||||||
{tripName && (
|
{tripName && (
|
||||||
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.subtitle, themedStyles.subtitle]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{tripName}
|
{tripName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -140,8 +162,14 @@ export default function TripCrewPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
|
<Ionicons
|
||||||
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}>
|
name="alert-circle-outline"
|
||||||
|
size={48}
|
||||||
|
color={colors.error || "#FF3B30"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.errorText, { color: colors.error || "#FF3B30" }]}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -152,12 +180,21 @@ export default function TripCrewPage() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<CrewList crews={crews} onEdit={handleEditCrew} onDelete={handleDeleteCrew} />
|
<CrewList
|
||||||
|
crews={crews}
|
||||||
|
onEdit={handleEditCrew}
|
||||||
|
onDelete={handleDeleteCrew}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer - Crew count */}
|
{/* Footer - Crew count */}
|
||||||
<View style={[styles.footer, { backgroundColor: colors.card, borderTopColor: colors.separator }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.footer,
|
||||||
|
{ backgroundColor: colors.card, borderTopColor: colors.separator },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.countContainer}>
|
<View style={styles.countContainer}>
|
||||||
<Ionicons name="people-outline" size={20} color={colors.primary} />
|
<Ionicons name="people-outline" size={20} color={colors.primary} />
|
||||||
<Text style={[styles.countText, { color: colors.text }]}>
|
<Text style={[styles.countText, { color: colors.text }]}>
|
||||||
@@ -175,6 +212,8 @@ export default function TripCrewPage() {
|
|||||||
}}
|
}}
|
||||||
onSave={handleSaveCrew}
|
onSave={handleSaveCrew}
|
||||||
mode={editingCrew ? "edit" : "add"}
|
mode={editingCrew ? "edit" : "add"}
|
||||||
|
tripId={tripId}
|
||||||
|
existingCrewIds={crews.map((c) => c.PersonalID || "")}
|
||||||
initialData={
|
initialData={
|
||||||
editingCrew
|
editingCrew
|
||||||
? {
|
? {
|
||||||
@@ -184,7 +223,8 @@ export default function TripCrewPage() {
|
|||||||
email: editingCrew.Person?.email || "",
|
email: editingCrew.Person?.email || "",
|
||||||
address: editingCrew.Person?.address || "",
|
address: editingCrew.Person?.address || "",
|
||||||
role: editingCrew.role || "crew",
|
role: editingCrew.role || "crew",
|
||||||
note: editingCrew.note || "",
|
// Note lấy từ trip (ghi chú chuyến đi), fallback về note từ Person
|
||||||
|
note: editingCrew.note || editingCrew.Person?.note || "",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -218,12 +258,20 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -237,7 +285,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -249,7 +301,11 @@ const styles = StyleSheet.create({
|
|||||||
errorText: {
|
errorText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
retryButton: {
|
retryButton: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -261,7 +317,11 @@ const styles = StyleSheet.create({
|
|||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
@@ -278,6 +338,10 @@ const styles = StyleSheet.create({
|
|||||||
countText: {
|
countText: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -12,10 +12,23 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Alert,
|
Alert,
|
||||||
|
Image,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
|
import {
|
||||||
|
searchCrew,
|
||||||
|
newTripCrew,
|
||||||
|
updateTripCrew,
|
||||||
|
} from "@/controller/TripCrewController";
|
||||||
|
import {
|
||||||
|
newCrew,
|
||||||
|
updateCrewInfo,
|
||||||
|
queryCrewImage,
|
||||||
|
} from "@/controller/CrewController";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
interface CrewFormData {
|
interface CrewFormData {
|
||||||
personalId: string;
|
personalId: string;
|
||||||
@@ -33,9 +46,12 @@ interface AddEditCrewModalProps {
|
|||||||
onSave: (data: CrewFormData) => Promise<void>;
|
onSave: (data: CrewFormData) => Promise<void>;
|
||||||
mode: "add" | "edit";
|
mode: "add" | "edit";
|
||||||
initialData?: Partial<CrewFormData>;
|
initialData?: Partial<CrewFormData>;
|
||||||
|
tripId?: string; // Required for add mode to add crew to trip
|
||||||
|
existingCrewIds?: string[]; // List of existing crew IDs in trip
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLES = ["captain", "crew", "engineer", "cook"];
|
const ROLES = ["captain", "crew"];
|
||||||
|
const DEBOUNCE_DELAY = 1000; // 3 seconds debounce
|
||||||
|
|
||||||
export default function AddEditCrewModal({
|
export default function AddEditCrewModal({
|
||||||
visible,
|
visible,
|
||||||
@@ -43,13 +59,17 @@ export default function AddEditCrewModal({
|
|||||||
onSave,
|
onSave,
|
||||||
mode,
|
mode,
|
||||||
initialData,
|
initialData,
|
||||||
|
tripId,
|
||||||
|
existingCrewIds = [],
|
||||||
}: AddEditCrewModalProps) {
|
}: AddEditCrewModalProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { colors } = useThemeContext();
|
const { colors } = useThemeContext();
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
const fadeAnim = useState(new Animated.Value(0))[0];
|
const fadeAnim = useState(new Animated.Value(0))[0];
|
||||||
const slideAnim = useState(new Animated.Value(Dimensions.get("window").height))[0];
|
const slideAnim = useState(
|
||||||
|
new Animated.Value(Dimensions.get("window").height)
|
||||||
|
)[0];
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<CrewFormData>({
|
const [formData, setFormData] = useState<CrewFormData>({
|
||||||
@@ -62,6 +82,21 @@ export default function AddEditCrewModal({
|
|||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isWaitingDebounce, setIsWaitingDebounce] = useState(false);
|
||||||
|
const [searchStatus, setSearchStatus] = useState<
|
||||||
|
"idle" | "found" | "not_found" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [foundPersonData, setFoundPersonData] =
|
||||||
|
useState<Model.TripCrewPerson | null>(null);
|
||||||
|
|
||||||
|
// State quản lý ảnh thuyền viên
|
||||||
|
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||||
|
const [newImageUri, setNewImageUri] = useState<string | null>(null);
|
||||||
|
const [isLoadingImage, setIsLoadingImage] = useState(false);
|
||||||
|
|
||||||
|
// Debounce timer ref
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Pre-fill form when editing
|
// Pre-fill form when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,6 +110,11 @@ export default function AddEditCrewModal({
|
|||||||
role: initialData.role || "crew",
|
role: initialData.role || "crew",
|
||||||
note: initialData.note || "",
|
note: initialData.note || "",
|
||||||
});
|
});
|
||||||
|
setSearchStatus("idle");
|
||||||
|
setFoundPersonData(null);
|
||||||
|
// Reset ảnh khi mở modal edit
|
||||||
|
setNewImageUri(null);
|
||||||
|
setImageUri(null);
|
||||||
} else if (visible && mode === "add") {
|
} else if (visible && mode === "add") {
|
||||||
// Reset form for add mode
|
// Reset form for add mode
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -86,9 +126,153 @@ export default function AddEditCrewModal({
|
|||||||
role: "crew",
|
role: "crew",
|
||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
|
setSearchStatus("idle");
|
||||||
|
setFoundPersonData(null);
|
||||||
|
// Reset ảnh
|
||||||
|
setNewImageUri(null);
|
||||||
|
setImageUri(null);
|
||||||
}
|
}
|
||||||
}, [visible, initialData, mode]);
|
}, [visible, initialData, mode]);
|
||||||
|
|
||||||
|
// Load ảnh thuyền viên khi edit
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCrewImage = async () => {
|
||||||
|
if (visible && mode === "edit" && initialData?.personalId) {
|
||||||
|
setIsLoadingImage(true);
|
||||||
|
try {
|
||||||
|
const response = await queryCrewImage(initialData.personalId);
|
||||||
|
if (response.data) {
|
||||||
|
const base64 = Buffer.from(response.data as ArrayBuffer).toString(
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
setImageUri(`data:image/jpeg;base64,${base64}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Không có ảnh hoặc lỗi - hiển thị placeholder
|
||||||
|
setImageUri(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingImage(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadCrewImage();
|
||||||
|
}, [visible, mode, initialData?.personalId]);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Search crew by personal ID with debounce
|
||||||
|
const searchCrewByPersonalId = useCallback(async (personalId: string) => {
|
||||||
|
if (!personalId.trim() || personalId.length < 5) {
|
||||||
|
setSearchStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchStatus("idle");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchCrew(personalId.trim());
|
||||||
|
if (response.data) {
|
||||||
|
const person = response.data;
|
||||||
|
setFoundPersonData(person);
|
||||||
|
setSearchStatus("found");
|
||||||
|
|
||||||
|
// Auto-fill form with found data
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: person.name || prev.name,
|
||||||
|
phone: person.phone || prev.phone,
|
||||||
|
email: person.email || prev.email,
|
||||||
|
address: person.address || prev.address,
|
||||||
|
note: person.note || prev.note,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setSearchStatus("not_found");
|
||||||
|
setFoundPersonData(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 404 or 401 means not found or unauthorized - treat as not found silently
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (status === 404 || status === 401) {
|
||||||
|
setSearchStatus("not_found");
|
||||||
|
} else {
|
||||||
|
// For other errors, just set to not_found without logging to console
|
||||||
|
setSearchStatus("not_found");
|
||||||
|
}
|
||||||
|
setFoundPersonData(null);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle personal ID change with debounce
|
||||||
|
const handlePersonalIdChange = (value: string) => {
|
||||||
|
// If crew was previously found and user is changing personal ID, reset form
|
||||||
|
if (foundPersonData && value !== formData.personalId) {
|
||||||
|
setFormData({
|
||||||
|
personalId: value,
|
||||||
|
name: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
address: "",
|
||||||
|
role: "crew",
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
setFoundPersonData(null);
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, personalId: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchStatus("idle");
|
||||||
|
setIsWaitingDebounce(false);
|
||||||
|
|
||||||
|
// Clear previous timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only search in add mode and when value is long enough
|
||||||
|
if (mode === "add" && value.trim().length >= 5) {
|
||||||
|
// Show waiting indicator
|
||||||
|
setIsWaitingDebounce(true);
|
||||||
|
|
||||||
|
// Set new timer
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setIsWaitingDebounce(false);
|
||||||
|
searchCrewByPersonalId(value);
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chọn ảnh từ thư viện
|
||||||
|
const pickImage = async () => {
|
||||||
|
// Xin quyền truy cập thư viện ảnh
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== "granted") {
|
||||||
|
Alert.alert(t("common.error"), "Cần cấp quyền truy cập thư viện ảnh");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mở image picker
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets[0]) {
|
||||||
|
setNewImageUri(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle animation
|
// Handle animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -108,6 +292,11 @@ export default function AddEditCrewModal({
|
|||||||
}, [visible, fadeAnim, slideAnim]);
|
}, [visible, fadeAnim, slideAnim]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// Clear debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(fadeAnim, {
|
Animated.timing(fadeAnim, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
@@ -125,7 +314,7 @@ export default function AddEditCrewModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Validate required fields
|
// === VALIDATE DỮ LIỆU ===
|
||||||
if (!formData.personalId.trim()) {
|
if (!formData.personalId.trim()) {
|
||||||
Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired"));
|
Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired"));
|
||||||
return;
|
return;
|
||||||
@@ -135,11 +324,65 @@ export default function AddEditCrewModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kiểm tra thuyền viên đã có trong chuyến đi chưa (chỉ với mode add)
|
||||||
|
if (
|
||||||
|
mode === "add" &&
|
||||||
|
existingCrewIds.includes(formData.personalId.trim())
|
||||||
|
) {
|
||||||
|
Alert.alert(t("common.error"), t("diary.crew.form.crewAlreadyExists"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
if (mode === "add" && tripId) {
|
||||||
|
// === CHẾ ĐỘ THÊM MỚI ===
|
||||||
|
|
||||||
|
// Bước 1: Nếu không tìm thấy thuyền viên trong hệ thống -> tạo mới
|
||||||
|
if (searchStatus === "not_found" || !foundPersonData) {
|
||||||
|
await newCrew({
|
||||||
|
personal_id: formData.personalId.trim(),
|
||||||
|
name: formData.name.trim(),
|
||||||
|
phone: formData.phone || "",
|
||||||
|
email: formData.email || "",
|
||||||
|
birth_date: new Date(),
|
||||||
|
note: formData.note || "",
|
||||||
|
address: formData.address || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bước 2: Thêm thuyền viên vào chuyến đi với role
|
||||||
|
await newTripCrew({
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: formData.personalId.trim(),
|
||||||
|
role: formData.role as "captain" | "crew",
|
||||||
|
});
|
||||||
|
} else if (mode === "edit" && tripId) {
|
||||||
|
// === CHẾ ĐỘ CHỈNH SỬA ===
|
||||||
|
|
||||||
|
// Bước 1: Cập nhật thông tin cá nhân của thuyền viên (không bao gồm note)
|
||||||
|
await updateCrewInfo(formData.personalId.trim(), {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
phone: formData.phone || "",
|
||||||
|
email: formData.email || "",
|
||||||
|
birth_date: new Date(),
|
||||||
|
address: formData.address || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bước 2: Cập nhật role và note của thuyền viên trong chuyến đi
|
||||||
|
await updateTripCrew({
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: formData.personalId.trim(),
|
||||||
|
role: formData.role as "captain" | "crew",
|
||||||
|
note: formData.note || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi callback để reload danh sách
|
||||||
await onSave(formData);
|
await onSave(formData);
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
console.error("Lỗi khi lưu thuyền viên:", error);
|
||||||
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
|
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -150,6 +393,65 @@ export default function AddEditCrewModal({
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render search status message
|
||||||
|
const renderSearchStatus = () => {
|
||||||
|
if (isSearching) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusContainer,
|
||||||
|
{ backgroundColor: colors.backgroundSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
|
||||||
|
{t("diary.crew.form.searching")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchStatus === "found") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusContainer,
|
||||||
|
styles.statusSuccess,
|
||||||
|
{ backgroundColor: colors.success + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color={colors.success} />
|
||||||
|
<Text style={[styles.statusText, { color: colors.success }]}>
|
||||||
|
{t("diary.crew.form.crewFound")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchStatus === "not_found") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusContainer,
|
||||||
|
styles.statusWarning,
|
||||||
|
{ backgroundColor: colors.warning + "15" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle"
|
||||||
|
size={18}
|
||||||
|
color={colors.warning}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.statusText, { color: colors.warning }]}>
|
||||||
|
{t("diary.crew.form.crewNotFound")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const themedStyles = {
|
const themedStyles = {
|
||||||
modalContainer: { backgroundColor: colors.card },
|
modalContainer: { backgroundColor: colors.card },
|
||||||
header: { borderBottomColor: colors.separator },
|
header: { borderBottomColor: colors.separator },
|
||||||
@@ -194,26 +496,96 @@ export default function AddEditCrewModal({
|
|||||||
<Ionicons name="close" size={24} color={colors.text} />
|
<Ionicons name="close" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text style={[styles.title, themedStyles.title]}>
|
<Text style={[styles.title, themedStyles.title]}>
|
||||||
{mode === "add" ? t("diary.crew.form.addTitle") : t("diary.crew.form.editTitle")}
|
{mode === "add"
|
||||||
|
? t("diary.crew.form.addTitle")
|
||||||
|
: t("diary.crew.form.editTitle")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.headerPlaceholder} />
|
<View style={styles.headerPlaceholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
|
style={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Ảnh thuyền viên */}
|
||||||
|
<View style={styles.photoSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.avatarContainer,
|
||||||
|
{ borderColor: colors.separator },
|
||||||
|
]}
|
||||||
|
onPress={pickImage}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{isLoadingImage ? (
|
||||||
|
<ActivityIndicator size="large" color={colors.primary} />
|
||||||
|
) : newImageUri || imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: newImageUri || imageUri || "" }}
|
||||||
|
style={styles.avatar}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.avatarPlaceholder,
|
||||||
|
{ backgroundColor: colors.backgroundSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="person"
|
||||||
|
size={50}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.photoHint, { color: colors.textSecondary }]}>
|
||||||
|
Nhấn để chọn ảnh
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Personal ID */}
|
{/* Personal ID */}
|
||||||
<View style={styles.formGroup}>
|
<View style={styles.formGroup}>
|
||||||
<Text style={[styles.label, themedStyles.label]}>
|
<Text style={[styles.label, themedStyles.label]}>
|
||||||
{t("diary.crew.personalId")} *
|
{t("diary.crew.personalId")} *
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<View style={styles.inputWithIcon}>
|
||||||
style={[styles.input, themedStyles.input]}
|
<TextInput
|
||||||
value={formData.personalId}
|
style={[
|
||||||
onChangeText={(v) => updateField("personalId", v)}
|
styles.input,
|
||||||
placeholder={t("diary.crew.form.personalIdPlaceholder")}
|
styles.inputWithIconInput,
|
||||||
placeholderTextColor={themedStyles.placeholder.color}
|
themedStyles.input,
|
||||||
editable={mode === "add"}
|
]}
|
||||||
/>
|
value={formData.personalId}
|
||||||
|
onChangeText={handlePersonalIdChange}
|
||||||
|
placeholder={t("diary.crew.form.personalIdPlaceholder")}
|
||||||
|
placeholderTextColor={themedStyles.placeholder.color}
|
||||||
|
editable={mode === "add"}
|
||||||
|
/>
|
||||||
|
{/* Show waiting icon during debounce, spinner during search */}
|
||||||
|
{(isWaitingDebounce || isSearching) && (
|
||||||
|
<View style={styles.inputIcon}>
|
||||||
|
{isSearching ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name="time-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{mode === "add" && (
|
||||||
|
<Text style={[styles.hint, { color: colors.textSecondary }]}>
|
||||||
|
{isWaitingDebounce
|
||||||
|
? t("diary.crew.form.waitingSearch")
|
||||||
|
: t("diary.crew.form.searchHint")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{renderSearchStatus()}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
@@ -309,10 +681,18 @@ export default function AddEditCrewModal({
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
|
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cancelButton, { backgroundColor: colors.backgroundSecondary }]}
|
style={[
|
||||||
|
styles.cancelButton,
|
||||||
|
{ backgroundColor: colors.backgroundSecondary },
|
||||||
|
]}
|
||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
>
|
>
|
||||||
<Text style={[styles.cancelButtonText, { color: colors.textSecondary }]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.cancelButtonText,
|
||||||
|
{ color: colors.textSecondary },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -363,7 +743,11 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
headerPlaceholder: {
|
headerPlaceholder: {
|
||||||
width: 32,
|
width: 32,
|
||||||
@@ -378,7 +762,11 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -386,7 +774,52 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
inputWithIcon: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
inputWithIconInput: {
|
||||||
|
paddingRight: 44,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
statusSuccess: {},
|
||||||
|
statusWarning: {},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
textArea: {
|
textArea: {
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -407,7 +840,11 @@ const styles = StyleSheet.create({
|
|||||||
roleButtonText: {
|
roleButtonText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -424,7 +861,11 @@ const styles = StyleSheet.create({
|
|||||||
cancelButtonText: {
|
cancelButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
saveButton: {
|
saveButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -436,9 +877,56 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
fontFamily: Platform.select({
|
||||||
|
ios: "System",
|
||||||
|
android: "Roboto",
|
||||||
|
default: "System",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
},
|
},
|
||||||
|
// Styles cho phần ảnh thuyền viên
|
||||||
|
photoSection: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
borderWidth: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
cameraIconOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "#FFFFFF",
|
||||||
|
},
|
||||||
|
photoHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { useI18n } from "@/hooks/use-i18n";
|
import { useI18n } from "@/hooks/use-i18n";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { queryCrewImage } from "@/controller/TripCrewController";
|
import { queryCrewImage } from "@/controller/CrewController";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
|
|
||||||
interface CrewCardProps {
|
interface CrewCardProps {
|
||||||
@@ -26,8 +26,12 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const person = crew.Person;
|
const person = crew.Person;
|
||||||
const joinedDate = crew.joined_at ? dayjs(crew.joined_at).format("DD/MM/YYYY") : "-";
|
const joinedDate = crew.joined_at
|
||||||
const leftDate = crew.left_at ? dayjs(crew.left_at).format("DD/MM/YYYY") : null;
|
? dayjs(crew.joined_at).format("DD/MM/YYYY")
|
||||||
|
: "-";
|
||||||
|
const leftDate = crew.left_at
|
||||||
|
? dayjs(crew.left_at).format("DD/MM/YYYY")
|
||||||
|
: null;
|
||||||
|
|
||||||
// State for image
|
// State for image
|
||||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||||
@@ -47,7 +51,9 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
const response = await queryCrewImage(person.personal_id);
|
const response = await queryCrewImage(person.personal_id);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
// Convert arraybuffer to base64
|
// Convert arraybuffer to base64
|
||||||
const base64 = Buffer.from(response.data as ArrayBuffer).toString("base64");
|
const base64 = Buffer.from(response.data as ArrayBuffer).toString(
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
setImageUri(`data:image/jpeg;base64,${base64}`);
|
setImageUri(`data:image/jpeg;base64,${base64}`);
|
||||||
} else {
|
} else {
|
||||||
setImageError(true);
|
setImageError(true);
|
||||||
@@ -112,7 +118,12 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
<Text style={[styles.name, themedStyles.name]} numberOfLines={1}>
|
<Text style={[styles.name, themedStyles.name]} numberOfLines={1}>
|
||||||
{person?.name || "-"}
|
{person?.name || "-"}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.roleBadge, { backgroundColor: themedStyles.role.backgroundColor }]}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.roleBadge,
|
||||||
|
{ backgroundColor: themedStyles.role.backgroundColor },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Text style={[styles.roleText, { color: themedStyles.role.color }]}>
|
<Text style={[styles.roleText, { color: themedStyles.role.color }]}>
|
||||||
{crew.role || t("diary.crew.member")}
|
{crew.role || t("diary.crew.member")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -123,7 +134,11 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
<View style={styles.infoGrid}>
|
<View style={styles.infoGrid}>
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Ionicons name="call-outline" size={14} color={themedStyles.iconColor} />
|
<Ionicons
|
||||||
|
name="call-outline"
|
||||||
|
size={14}
|
||||||
|
color={themedStyles.iconColor}
|
||||||
|
/>
|
||||||
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
||||||
{person?.phone || "-"}
|
{person?.phone || "-"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -131,7 +146,11 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
|
|
||||||
{/* Personal ID */}
|
{/* Personal ID */}
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Ionicons name="card-outline" size={14} color={themedStyles.iconColor} />
|
<Ionicons
|
||||||
|
name="card-outline"
|
||||||
|
size={14}
|
||||||
|
color={themedStyles.iconColor}
|
||||||
|
/>
|
||||||
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
||||||
{person?.personal_id || "-"}
|
{person?.personal_id || "-"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -139,14 +158,22 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
|
|
||||||
{/* Joined Date */}
|
{/* Joined Date */}
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Ionicons name="calendar-outline" size={14} color={themedStyles.iconColor} />
|
<Ionicons
|
||||||
|
name="calendar-outline"
|
||||||
|
size={14}
|
||||||
|
color={themedStyles.iconColor}
|
||||||
|
/>
|
||||||
<Text style={[styles.value, themedStyles.value]}>{joinedDate}</Text>
|
<Text style={[styles.value, themedStyles.value]}>{joinedDate}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Left Date (only show if exists) */}
|
{/* Left Date (only show if exists) */}
|
||||||
{leftDate && (
|
{leftDate && (
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Ionicons name="exit-outline" size={14} color={themedStyles.iconColor} />
|
<Ionicons
|
||||||
|
name="exit-outline"
|
||||||
|
size={14}
|
||||||
|
color={themedStyles.iconColor}
|
||||||
|
/>
|
||||||
<Text style={[styles.value, themedStyles.value]}>{leftDate}</Text>
|
<Text style={[styles.value, themedStyles.value]}>{leftDate}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -157,10 +184,17 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, { backgroundColor: colors.primary + "15" }]}
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
{ backgroundColor: colors.primary + "15" },
|
||||||
|
]}
|
||||||
onPress={() => onEdit(crew)}
|
onPress={() => onEdit(crew)}
|
||||||
>
|
>
|
||||||
<Ionicons name="pencil-outline" size={14} color={colors.primary} />
|
<Ionicons
|
||||||
|
name="pencil-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.primary}
|
||||||
|
/>
|
||||||
<Text style={[styles.actionText, { color: colors.primary }]}>
|
<Text style={[styles.actionText, { color: colors.primary }]}>
|
||||||
{t("common.edit")}
|
{t("common.edit")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -168,11 +202,23 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
|||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, { backgroundColor: (colors.error || "#FF3B30") + "15" }]}
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
{ backgroundColor: (colors.error || "#FF3B30") + "15" },
|
||||||
|
]}
|
||||||
onPress={() => onDelete(crew)}
|
onPress={() => onDelete(crew)}
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={14} color={colors.error || "#FF3B30"} />
|
<Ionicons
|
||||||
<Text style={[styles.actionText, { color: colors.error || "#FF3B30" }]}>
|
name="trash-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.error || "#FF3B30"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.actionText,
|
||||||
|
{ color: colors.error || "#FF3B30" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -191,7 +237,7 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
maxHeight: 150,
|
maxHeight: 160,
|
||||||
},
|
},
|
||||||
imageSection: {
|
imageSection: {
|
||||||
width: 130,
|
width: 130,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const codeMessage = {
|
|||||||
|
|
||||||
// Tạo instance axios với cấu hình cơ bản
|
// Tạo instance axios với cấu hình cơ bản
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "https://sgw.gms.vn",
|
baseURL: "https://sgw.gms.vn",
|
||||||
timeout: 20000, // Timeout 20 giây
|
timeout: 20000, // Timeout 20 giây
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -72,8 +72,9 @@ api.interceptors.response.use(
|
|||||||
statusText ||
|
statusText ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
|
|
||||||
// Không hiển thị toast cho status 400 (validation errors)
|
// Không hiển thị toast cho status 400 (validation errors) và 404 (not found)
|
||||||
if (status !== 400) {
|
// 404 được xử lý riêng bởi component (ví dụ: search crew không tìm thấy)
|
||||||
|
if (status !== 400 && status !== 404) {
|
||||||
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
showErrorToast(`Lỗi ${status}: ${errMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,6 @@ export const API_GET_ALL_SHIP = "/api/sgw/ships";
|
|||||||
export const API_GET_ALL_PORT = "/api/sgw/ports";
|
export const API_GET_ALL_PORT = "/api/sgw/ports";
|
||||||
export const API_GET_PHOTO = "/api/sgw/photo";
|
export const API_GET_PHOTO = "/api/sgw/photo";
|
||||||
export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";
|
export const API_GET_TRIP_CREW = "/api/sgw/trips/crews";
|
||||||
|
export const API_SEARCH_CREW = "/api/sgw/trips/crew/";
|
||||||
|
export const API_TRIP_CREW = "/api/sgw/tripcrew";
|
||||||
|
export const API_CREW = "/api/sgw/crew";
|
||||||
|
|||||||
19
controller/CrewController.ts
Normal file
19
controller/CrewController.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_CREW, API_GET_PHOTO } from "@/constants";
|
||||||
|
|
||||||
|
export async function newCrew(body: Model.NewCrewAPIRequest) {
|
||||||
|
return api.post(API_CREW, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCrewInfo(
|
||||||
|
personalId: string,
|
||||||
|
body: Model.UpdateCrewAPIRequest
|
||||||
|
) {
|
||||||
|
return api.put(`${API_CREW}/${personalId}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryCrewImage(personal_id: string) {
|
||||||
|
return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
API_GET_LAST_TRIP,
|
API_GET_LAST_TRIP,
|
||||||
API_POST_TRIP,
|
API_POST_TRIP,
|
||||||
API_PUT_TRIP,
|
API_PUT_TRIP,
|
||||||
|
API_TRIP_CREW,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
|
|
||||||
export async function queryTrip() {
|
export async function queryTrip() {
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { api } from "@/config";
|
import { api } from "@/config";
|
||||||
import { API_GET_PHOTO, API_GET_TRIP_CREW } from "@/constants";
|
import {
|
||||||
|
API_GET_PHOTO,
|
||||||
|
API_GET_TRIP_CREW,
|
||||||
|
API_SEARCH_CREW,
|
||||||
|
API_TRIP_CREW,
|
||||||
|
} from "@/constants";
|
||||||
|
|
||||||
export async function queryTripCrew(tripId: string) {
|
export async function queryTripCrew(tripId: string) {
|
||||||
return api.get<Model.TripCrews[]>(`${API_GET_TRIP_CREW}/${tripId}`);
|
return api.get<Model.TripCrews[]>(`${API_GET_TRIP_CREW}/${tripId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryCrewImage(personal_id: string) {
|
export async function searchCrew(personal_id: string) {
|
||||||
return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, {
|
return api.get<Model.TripCrewPerson>(`${API_SEARCH_CREW}/${personal_id}`);
|
||||||
responseType: "arraybuffer",
|
}
|
||||||
});
|
|
||||||
|
export async function newTripCrew(body: Model.NewTripCrewAPIRequest) {
|
||||||
|
return api.post(API_TRIP_CREW, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTripCrew(body: Model.UpdateTripCrewAPIRequest) {
|
||||||
|
return api.put(API_TRIP_CREW, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTripCrew(tripId: string, personalId: string) {
|
||||||
|
return api.delete(`${API_TRIP_CREW}/${tripId}/${personalId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
31
controller/typings.d.ts
vendored
31
controller/typings.d.ts
vendored
@@ -154,6 +154,37 @@ declare namespace Model {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TripCrew Request Body
|
||||||
|
interface NewTripCrewAPIRequest {
|
||||||
|
trip_id: string;
|
||||||
|
personal_id: string;
|
||||||
|
role: "captain" | "crew";
|
||||||
|
}
|
||||||
|
interface UpdateTripCrewAPIRequest {
|
||||||
|
trip_id: string;
|
||||||
|
personal_id: string;
|
||||||
|
role: "captain" | "crew";
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
interface NewCrewAPIRequest {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date;
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
interface UpdateCrewAPIRequest {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date;
|
||||||
|
//note: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Chi phí chuyến đi
|
// Chi phí chuyến đi
|
||||||
interface TripCost {
|
interface TripCost {
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -154,9 +154,17 @@
|
|||||||
"departurePort": "Departure Port",
|
"departurePort": "Departure Port",
|
||||||
"arrivalPort": "Arrival Port",
|
"arrivalPort": "Arrival Port",
|
||||||
"selectPort": "Select port",
|
"selectPort": "Select port",
|
||||||
|
"searchPort": "Search port...",
|
||||||
|
"noPortsFound": "No ports found",
|
||||||
"fishingGroundCodes": "Fishing Ground Codes",
|
"fishingGroundCodes": "Fishing Ground Codes",
|
||||||
"fishingGroundCodesHint": "Enter fishing ground codes (comma separated)",
|
"fishingGroundCodesHint": "Enter fishing ground codes (comma separated)",
|
||||||
"fishingGroundCodesPlaceholder": "e.g: 1,2,3",
|
"fishingGroundCodesPlaceholder": "e.g: 1,2,3",
|
||||||
|
"formSection": {
|
||||||
|
"basicInfo": "Basic Information",
|
||||||
|
"schedule": "Schedule & Location",
|
||||||
|
"equipment": "Fishing Gear",
|
||||||
|
"costs": "Trip Costs"
|
||||||
|
},
|
||||||
"autoFill": {
|
"autoFill": {
|
||||||
"title": "Auto-fill data",
|
"title": "Auto-fill data",
|
||||||
"description": "Fill from the ship's last trip",
|
"description": "Fill from the ship's last trip",
|
||||||
@@ -213,8 +221,57 @@
|
|||||||
"address": "Address",
|
"address": "Address",
|
||||||
"addressPlaceholder": "Enter address",
|
"addressPlaceholder": "Enter address",
|
||||||
"notePlaceholder": "Enter note (optional)",
|
"notePlaceholder": "Enter note (optional)",
|
||||||
"saveError": "Unable to save. Please try again."
|
"saveError": "Unable to save. Please try again.",
|
||||||
|
"searchHint": "Enter ID number, system will auto-search",
|
||||||
|
"waitingSearch": "Waiting to search...",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"crewFound": "Crew member found, data has been auto-filled",
|
||||||
|
"crewNotFound": "Crew member not found, please enter information to create new",
|
||||||
|
"crewAlreadyExists": "This crew member is already in the trip"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tripDetail": {
|
||||||
|
"title": "Trip Details",
|
||||||
|
"notFound": "Trip information not found",
|
||||||
|
"basicInfo": "Basic Information",
|
||||||
|
"shipId": "VMS Ship Code",
|
||||||
|
"departureTime": "Departure Time",
|
||||||
|
"arrivalTime": "Arrival Time",
|
||||||
|
"departurePort": "Departure Port",
|
||||||
|
"arrivalPort": "Arrival Port",
|
||||||
|
"fishingGrounds": "Fishing Grounds",
|
||||||
|
"alerts": "Alert List",
|
||||||
|
"noAlerts": "No alerts",
|
||||||
|
"unknownAlert": "Unknown alert",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"costs": "Trip Costs",
|
||||||
|
"noCosts": "No costs",
|
||||||
|
"unknownCost": "Unknown cost",
|
||||||
|
"totalCost": "Total Cost",
|
||||||
|
"gears": "Fishing Gear List",
|
||||||
|
"noGears": "No fishing gear",
|
||||||
|
"unknownGear": "Unknown gear",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"crew": "Crew List",
|
||||||
|
"noCrew": "No crew",
|
||||||
|
"unknownCrew": "Unknown crew member",
|
||||||
|
"roleCaptain": "Captain",
|
||||||
|
"roleCrew": "Crew",
|
||||||
|
"roleEngineer": "Engineer",
|
||||||
|
"fishingLogs": "Fishing Log List",
|
||||||
|
"noFishingLogs": "No fishing logs",
|
||||||
|
"startTime": "Start",
|
||||||
|
"endTime": "End",
|
||||||
|
"startLocation": "Start Location",
|
||||||
|
"haulLocation": "Haul Location",
|
||||||
|
"catchInfo": "Catch Info",
|
||||||
|
"species": "species",
|
||||||
|
"unknownFish": "Unknown fish",
|
||||||
|
"more": "more species",
|
||||||
|
"logStatusPending": "Pending",
|
||||||
|
"logStatusActive": "Active",
|
||||||
|
"logStatusCompleted": "Completed",
|
||||||
|
"logStatusUnknown": "Unknown"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trip": {
|
"trip": {
|
||||||
|
|||||||
@@ -221,7 +221,13 @@
|
|||||||
"address": "Địa chỉ",
|
"address": "Địa chỉ",
|
||||||
"addressPlaceholder": "Nhập địa chỉ",
|
"addressPlaceholder": "Nhập địa chỉ",
|
||||||
"notePlaceholder": "Nhập ghi chú (nếu có)",
|
"notePlaceholder": "Nhập ghi chú (nếu có)",
|
||||||
"saveError": "Không thể lưu thông tin. Vui lòng thử lại."
|
"saveError": "Không thể lưu thông tin. Vui lòng thử lại.",
|
||||||
|
"searchHint": "Nhập số định danh, hệ thống sẽ tự động tìm kiếm",
|
||||||
|
"waitingSearch": "Đang chờ tìm kiếm...",
|
||||||
|
"searching": "Đang tìm kiếm...",
|
||||||
|
"crewFound": "Đã tìm thấy thuyền viên, dữ liệu đã được điền tự động",
|
||||||
|
"crewNotFound": "Không tìm thấy thuyền viên, vui lòng nhập thông tin để tạo mới",
|
||||||
|
"crewAlreadyExists": "Thuyền viên này đã có trong chuyến đi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tripDetail": {
|
"tripDetail": {
|
||||||
|
|||||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"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-picker": "~17.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,6 +8546,27 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image-loader": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-image-picker": {
|
||||||
|
"version": "17.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
|
||||||
|
"integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"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-picker": "~17.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",
|
||||||
|
|||||||
Reference in New Issue
Block a user