Cập nhật tab Nhật ký ( CRUD chuyến đi, CRUD thuyền viên trong chuyến đi )
This commit is contained in:
@@ -17,15 +17,14 @@ import {
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import {
|
||||
searchCrew,
|
||||
newTripCrew,
|
||||
updateTripCrew,
|
||||
} from "@/controller/TripCrewController";
|
||||
|
||||
import { newTripCrew, updateTripCrew } from "@/controller/TripCrewController";
|
||||
import {
|
||||
newCrew,
|
||||
searchCrew,
|
||||
updateCrewInfo,
|
||||
queryCrewImage,
|
||||
uploadCrewImage,
|
||||
} from "@/controller/CrewController";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { Buffer } from "buffer";
|
||||
@@ -48,10 +47,11 @@ interface AddEditCrewModalProps {
|
||||
initialData?: Partial<CrewFormData>;
|
||||
tripId?: string; // Required for add mode to add crew to trip
|
||||
existingCrewIds?: string[]; // List of existing crew IDs in trip
|
||||
tripStatus?: number; // Trạng thái chuyến đi để validate (type number)
|
||||
}
|
||||
|
||||
const ROLES = ["captain", "crew"];
|
||||
const DEBOUNCE_DELAY = 1000; // 3 seconds debounce
|
||||
const DEBOUNCE_DELAY = 1000; // 1 seconds debounce
|
||||
|
||||
export default function AddEditCrewModal({
|
||||
visible,
|
||||
@@ -61,6 +61,7 @@ export default function AddEditCrewModal({
|
||||
initialData,
|
||||
tripId,
|
||||
existingCrewIds = [],
|
||||
tripStatus,
|
||||
}: AddEditCrewModalProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
@@ -191,8 +192,24 @@ export default function AddEditCrewModal({
|
||||
phone: person.phone || prev.phone,
|
||||
email: person.email || prev.email,
|
||||
address: person.address || prev.address,
|
||||
note: person.note || prev.note,
|
||||
}));
|
||||
|
||||
// Load avatar của thuyền viên đã tìm thấy
|
||||
try {
|
||||
setIsLoadingImage(true);
|
||||
const imageResponse = await queryCrewImage(personalId.trim());
|
||||
if (imageResponse.data) {
|
||||
const base64 = Buffer.from(
|
||||
imageResponse.data as ArrayBuffer
|
||||
).toString("base64");
|
||||
setImageUri(`data:image/jpeg;base64,${base64}`);
|
||||
}
|
||||
} catch (imageError) {
|
||||
// Không có ảnh - hiển thị placeholder
|
||||
setImageUri(null);
|
||||
} finally {
|
||||
setIsLoadingImage(false);
|
||||
}
|
||||
} else {
|
||||
setSearchStatus("not_found");
|
||||
setFoundPersonData(null);
|
||||
@@ -337,16 +354,18 @@ export default function AddEditCrewModal({
|
||||
try {
|
||||
if (mode === "add" && tripId) {
|
||||
// === CHẾ ĐỘ THÊM MỚI ===
|
||||
const isNewCrew = searchStatus === "not_found" || !foundPersonData;
|
||||
|
||||
// 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) {
|
||||
// Note: KHÔNG gửi note vào newCrew vì note là riêng cho từng chuyến đi
|
||||
if (isNewCrew) {
|
||||
await newCrew({
|
||||
personal_id: formData.personalId.trim(),
|
||||
name: formData.name.trim(),
|
||||
phone: formData.phone || "",
|
||||
email: formData.email || "",
|
||||
birth_date: new Date(),
|
||||
note: formData.note || "",
|
||||
note: "", // Không gửi note - note là riêng cho từng chuyến đi
|
||||
address: formData.address || "",
|
||||
});
|
||||
}
|
||||
@@ -361,21 +380,54 @@ export default function AddEditCrewModal({
|
||||
// === 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 || "",
|
||||
});
|
||||
try {
|
||||
await updateCrewInfo(formData.personalId.trim(), {
|
||||
name: formData.name.trim(),
|
||||
phone: formData.phone || "",
|
||||
email: formData.email || "",
|
||||
birth_date: new Date(),
|
||||
address: formData.address || "",
|
||||
});
|
||||
console.log("✅ updateCrewInfo thành công");
|
||||
} catch (crewError: any) {
|
||||
console.error("❌ Lỗi updateCrewInfo:", crewError.response?.data);
|
||||
}
|
||||
|
||||
// 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 || "",
|
||||
});
|
||||
// Bước 2: Cập nhật role và note (chỉ khi chuyến đi chưa hoàn thành)
|
||||
// TripStatus: 0=created, 1=pending, 2=approved, 3=active, 4=completed, 5=cancelled
|
||||
if (tripStatus !== 4) {
|
||||
try {
|
||||
await updateTripCrew({
|
||||
trip_id: tripId,
|
||||
personal_id: formData.personalId.trim(),
|
||||
role: formData.role as "captain" | "crew",
|
||||
note: formData.note || "",
|
||||
});
|
||||
console.log("✅ updateTripCrew thành công");
|
||||
} catch (tripCrewError: any) {
|
||||
console.error(
|
||||
"❌ Lỗi updateTripCrew:",
|
||||
tripCrewError.response?.data
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Chuyến đi đã hoàn thành - không cho update role/note
|
||||
console.log("⚠️ Chuyến đi đã hoàn thành - bỏ qua updateTripCrew");
|
||||
}
|
||||
}
|
||||
|
||||
// Upload ảnh nếu có ảnh mới được chọn
|
||||
if (newImageUri && formData.personalId.trim()) {
|
||||
try {
|
||||
console.log("📤 Uploading image:", newImageUri);
|
||||
await uploadCrewImage(formData.personalId.trim(), newImageUri);
|
||||
console.log("✅ Upload ảnh thành công");
|
||||
} catch (uploadError: any) {
|
||||
console.error("❌ Lỗi upload ảnh:", uploadError);
|
||||
console.error("Response data:", uploadError.response?.data);
|
||||
console.error("Response status:", uploadError.response?.status);
|
||||
// Không throw error vì thông tin crew đã được lưu thành công
|
||||
}
|
||||
}
|
||||
|
||||
// Gọi callback để reload danh sách
|
||||
@@ -383,6 +435,8 @@ export default function AddEditCrewModal({
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
console.error("Lỗi khi lưu thuyền viên:", error);
|
||||
console.error("Response data:", error.response?.data);
|
||||
console.error("Response status:", error.response?.status);
|
||||
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -507,6 +561,9 @@ export default function AddEditCrewModal({
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingBottom: 100 }}
|
||||
automaticallyAdjustKeyboardInsets={true}
|
||||
>
|
||||
{/* Ảnh thuyền viên */}
|
||||
<View style={styles.photoSection}>
|
||||
@@ -661,21 +718,23 @@ export default function AddEditCrewModal({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Note */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.crew.note")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea, themedStyles.input]}
|
||||
value={formData.note}
|
||||
onChangeText={(v) => updateField("note", v)}
|
||||
placeholder={t("diary.crew.form.notePlaceholder")}
|
||||
placeholderTextColor={themedStyles.placeholder.color}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
{/* Note - Chỉ hiển thị khi edit, ẩn khi add */}
|
||||
{mode === "edit" && (
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.crew.note")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea, themedStyles.input]}
|
||||
value={formData.note}
|
||||
onChangeText={(v) => updateField("note", v)}
|
||||
placeholder={t("diary.crew.form.notePlaceholder")}
|
||||
placeholderTextColor={themedStyles.placeholder.color}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -849,7 +908,8 @@ const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
padding: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
cancelButton: {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { View, Text, StyleSheet, FlatList, Platform } from "react-native";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import CrewCard from "./CrewCard";
|
||||
@@ -24,7 +18,7 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
|
||||
<CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} />
|
||||
);
|
||||
|
||||
const keyExtractor = (item: Model.TripCrews, index: number) =>
|
||||
const keyExtractor = (item: Model.TripCrews, index: number) =>
|
||||
`${item.PersonalID}-${index}`;
|
||||
|
||||
const renderEmpty = () => (
|
||||
@@ -43,6 +37,8 @@ export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
|
||||
ListEmptyComponent={renderEmpty}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
scrollEnabled={false}
|
||||
nestedScrollEnabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ export default function TripCrewModal({
|
||||
|
||||
// Animation values
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
|
||||
const slideAnim = useRef(
|
||||
new Animated.Value(Dimensions.get("window").height)
|
||||
).current;
|
||||
|
||||
// State
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -81,7 +83,7 @@ export default function TripCrewModal({
|
||||
try {
|
||||
const response = await queryTripCrew(tripId);
|
||||
const data = response.data as any;
|
||||
|
||||
|
||||
if (data?.trip_crews && Array.isArray(data.trip_crews)) {
|
||||
setCrews(data.trip_crews);
|
||||
} else if (Array.isArray(data)) {
|
||||
@@ -144,7 +146,9 @@ export default function TripCrewModal({
|
||||
onPress: async () => {
|
||||
// TODO: Call delete API when available
|
||||
// For now, just remove from local state
|
||||
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
|
||||
setCrews((prev) =>
|
||||
prev.filter((c) => c.PersonalID !== crew.PersonalID)
|
||||
);
|
||||
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
|
||||
},
|
||||
},
|
||||
@@ -152,10 +156,8 @@ export default function TripCrewModal({
|
||||
);
|
||||
};
|
||||
|
||||
// Save crew handler (add or edit)
|
||||
const handleSaveCrew = async (formData: any) => {
|
||||
// TODO: Call API to add/edit crew when available
|
||||
// For now, refresh the list
|
||||
// Save crew handler - API được gọi trong AddEditCrewModal, sau đó refresh danh sách
|
||||
const handleSaveCrew = async () => {
|
||||
await fetchCrewData();
|
||||
};
|
||||
|
||||
@@ -185,7 +187,10 @@ export default function TripCrewModal({
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, themedStyles.header]}>
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
style={styles.closeButton}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerTitles}>
|
||||
@@ -193,13 +198,19 @@ export default function TripCrewModal({
|
||||
{t("diary.crew.title")}
|
||||
</Text>
|
||||
{tripName && (
|
||||
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
|
||||
<Text
|
||||
style={[styles.subtitle, themedStyles.subtitle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tripName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
|
||||
<TouchableOpacity
|
||||
onPress={handleAddCrew}
|
||||
style={styles.addButton}
|
||||
>
|
||||
<Ionicons name="add" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -209,21 +220,40 @@ export default function TripCrewModal({
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.loadingText,
|
||||
{ color: colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{t("diary.crew.loading")}
|
||||
</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
|
||||
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}>
|
||||
<Ionicons
|
||||
name="alert-circle-outline"
|
||||
size={48}
|
||||
color={colors.error || "#FF3B30"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.errorText,
|
||||
{ color: colors.error || "#FF3B30" },
|
||||
]}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: colors.primary }]}
|
||||
style={[
|
||||
styles.retryButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={fetchCrewData}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
|
||||
<Text style={styles.retryButtonText}>
|
||||
{t("common.retry")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -238,7 +268,11 @@ export default function TripCrewModal({
|
||||
{/* Footer - Crew count */}
|
||||
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
|
||||
<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 }]}>
|
||||
{t("diary.crew.totalMembers", { count: crews.length })}
|
||||
</Text>
|
||||
@@ -257,6 +291,8 @@ export default function TripCrewModal({
|
||||
}}
|
||||
onSave={handleSaveCrew}
|
||||
mode={editingCrew ? "edit" : "add"}
|
||||
tripId={tripId || undefined}
|
||||
existingCrewIds={crews.map((c) => c.PersonalID)}
|
||||
initialData={
|
||||
editingCrew
|
||||
? {
|
||||
@@ -313,12 +349,20 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -332,7 +376,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
@@ -344,7 +392,11 @@ const styles = StyleSheet.create({
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 8,
|
||||
@@ -356,7 +408,11 @@ const styles = StyleSheet.create({
|
||||
color: "#FFFFFF",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
@@ -372,6 +428,10 @@ const styles = StyleSheet.create({
|
||||
countText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user