cập nhật modal CRUD thuyền viên trong trip

This commit is contained in:
2025-12-24 21:58:18 +07:00
parent 24847504b1
commit 190e44b09e
13 changed files with 822 additions and 68 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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
View File

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

View File

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