Files
sgw-owner-app/components/diary/TripCrewModal/AddEditCrewModal.tsx

993 lines
28 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
TextInput,
ScrollView,
ActivityIndicator,
Animated,
Dimensions,
Alert,
Image,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
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";
interface CrewFormData {
personalId: string;
name: string;
phone: string;
email: string;
address: string;
role: string;
note: string;
}
interface AddEditCrewModalProps {
visible: boolean;
onClose: () => void;
onSave: (data: CrewFormData) => Promise<void>;
mode: "add" | "edit";
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; // 1 seconds debounce
export default function AddEditCrewModal({
visible,
onClose,
onSave,
mode,
initialData,
tripId,
existingCrewIds = [],
tripStatus,
}: AddEditCrewModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Animation values
const fadeAnim = useState(new Animated.Value(0))[0];
const slideAnim = useState(
new Animated.Value(Dimensions.get("window").height)
)[0];
// Form state
const [formData, setFormData] = useState<CrewFormData>({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
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
useEffect(() => {
if (visible && initialData) {
setFormData({
personalId: initialData.personalId || "",
name: initialData.name || "",
phone: initialData.phone || "",
email: initialData.email || "",
address: initialData.address || "",
role: initialData.role || "crew",
note: initialData.note || "",
});
setSearchStatus("idle");
setFoundPersonData(null);
// Reset ảnh khi mở modal edit
setNewImageUri(null);
setImageUri(null);
} else if (visible && mode === "add") {
// Reset form for add mode
setFormData({
personalId: "",
name: "",
phone: "",
email: "",
address: "",
role: "crew",
note: "",
});
setSearchStatus("idle");
setFoundPersonData(null);
// Reset ảnh
setNewImageUri(null);
setImageUri(null);
}
}, [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,
}));
// 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);
}
} 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
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [visible, fadeAnim, slideAnim]);
const handleClose = () => {
// Clear debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: Dimensions.get("window").height,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
onClose();
});
};
const handleSave = async () => {
// === VALIDATE DỮ LIỆU ===
if (!formData.personalId.trim()) {
Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired"));
return;
}
if (!formData.name.trim()) {
Alert.alert(t("common.error"), t("diary.crew.form.nameRequired"));
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);
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
// 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: "", // Không gửi note - note là riêng cho từng chuyến đi
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)
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 (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
await onSave(formData);
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);
}
};
const updateField = (field: keyof CrewFormData, value: string) => {
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 = {
modalContainer: { backgroundColor: colors.card },
header: { borderBottomColor: colors.separator },
title: { color: colors.text },
label: { color: colors.text },
input: {
backgroundColor: colors.backgroundSecondary,
color: colors.text,
borderColor: colors.separator,
},
placeholder: { color: colors.textSecondary },
roleButton: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.separator,
},
roleButtonActive: {
backgroundColor: colors.primary + "20",
borderColor: colors.primary,
},
roleText: { color: colors.textSecondary },
roleTextActive: { color: colors.primary },
};
return (
<Modal
visible={visible}
animationType="none"
transparent
onRequestClose={handleClose}
>
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
<Animated.View
style={[
styles.modalContainer,
themedStyles.modalContainer,
{ transform: [{ translateY: slideAnim }] },
]}
>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{mode === "add"
? t("diary.crew.form.addTitle")
: t("diary.crew.form.editTitle")}
</Text>
<View style={styles.headerPlaceholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 100 }}
automaticallyAdjustKeyboardInsets={true}
>
{/* Ả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 */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.personalId")} *
</Text>
<View style={styles.inputWithIcon}>
<TextInput
style={[
styles.input,
styles.inputWithIconInput,
themedStyles.input,
]}
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>
{/* Name */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.name")} *
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.name}
onChangeText={(v) => updateField("name", v)}
placeholder={t("diary.crew.form.namePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</View>
{/* Phone */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.phone")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.phone}
onChangeText={(v) => updateField("phone", v)}
placeholder={t("diary.crew.form.phonePlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
keyboardType="phone-pad"
/>
</View>
{/* Role */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.role")}
</Text>
<View style={styles.roleContainer}>
{ROLES.map((role) => (
<TouchableOpacity
key={role}
style={[
styles.roleButton,
themedStyles.roleButton,
formData.role === role && themedStyles.roleButtonActive,
]}
onPress={() => updateField("role", role)}
>
<Text
style={[
styles.roleButtonText,
themedStyles.roleText,
formData.role === role && themedStyles.roleTextActive,
]}
>
{t(`diary.crew.roles.${role}`)}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Address */}
<View style={styles.formGroup}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.crew.form.address")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={formData.address}
onChangeText={(v) => updateField("address", v)}
placeholder={t("diary.crew.form.addressPlaceholder")}
placeholderTextColor={themedStyles.placeholder.color}
/>
</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 */}
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
<TouchableOpacity
style={[
styles.cancelButton,
{ backgroundColor: colors.backgroundSecondary },
]}
onPress={handleClose}
>
<Text
style={[
styles.cancelButtonText,
{ color: colors.textSecondary },
]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.saveButton,
{ backgroundColor: colors.primary },
isSubmitting && styles.buttonDisabled,
]}
onPress={handleSave}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.saveButtonText}>{t("common.save")}</Text>
)}
</TouchableOpacity>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
headerPlaceholder: {
width: 32,
},
content: {
padding: 20,
},
formGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
marginBottom: 8,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
input: {
height: 44,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 14,
fontSize: 15,
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: {
height: 80,
paddingTop: 12,
textAlignVertical: "top",
},
roleContainer: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
roleButton: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
roleButtonText: {
fontSize: 13,
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
footer: {
flexDirection: "row",
gap: 12,
paddingHorizontal: 20,
paddingVertical: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
saveButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
buttonDisabled: {
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,
},
});