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:
2025-12-29 15:56:47 +07:00
parent 190e44b09e
commit 871360af49
24 changed files with 1451 additions and 407 deletions

View File

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

View File

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

View File

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