thêm tab "Xem chi tiết chuyến đi", "Xem chi tiết thành viên chuyến đi", tái sử dụng lại components modal tripForm
This commit is contained in:
444
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal file
444
components/diary/TripCrewModal/AddEditCrewModal.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
const ROLES = ["captain", "crew", "engineer", "cook"];
|
||||
|
||||
export default function AddEditCrewModal({
|
||||
visible,
|
||||
onClose,
|
||||
onSave,
|
||||
mode,
|
||||
initialData,
|
||||
}: 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);
|
||||
|
||||
// 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 || "",
|
||||
});
|
||||
} else if (visible && mode === "add") {
|
||||
// Reset form for add mode
|
||||
setFormData({
|
||||
personalId: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
role: "crew",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
}, [visible, initialData, mode]);
|
||||
|
||||
// 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 = () => {
|
||||
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 required fields
|
||||
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;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
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 }));
|
||||
};
|
||||
|
||||
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}>
|
||||
{/* Personal ID */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, themedStyles.label]}>
|
||||
{t("diary.crew.personalId")} *
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, themedStyles.input]}
|
||||
value={formData.personalId}
|
||||
onChangeText={(v) => updateField("personalId", v)}
|
||||
placeholder={t("diary.crew.form.personalIdPlaceholder")}
|
||||
placeholderTextColor={themedStyles.placeholder.color}
|
||||
editable={mode === "add"}
|
||||
/>
|
||||
</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 */}
|
||||
<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" }),
|
||||
},
|
||||
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,
|
||||
padding: 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,
|
||||
},
|
||||
});
|
||||
285
components/diary/TripCrewModal/CrewCard.tsx
Normal file
285
components/diary/TripCrewModal/CrewCard.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import dayjs from "dayjs";
|
||||
import { queryCrewImage } from "@/controller/TripCrewController";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
interface CrewCardProps {
|
||||
crew: Model.TripCrews;
|
||||
onEdit?: (crew: Model.TripCrews) => void;
|
||||
onDelete?: (crew: Model.TripCrews) => void;
|
||||
}
|
||||
|
||||
export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const person = crew.Person;
|
||||
const joinedDate = crew.joined_at ? 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
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Fetch crew image
|
||||
useEffect(() => {
|
||||
const fetchImage = async () => {
|
||||
if (!person?.personal_id) {
|
||||
setImageLoading(false);
|
||||
setImageError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryCrewImage(person.personal_id);
|
||||
if (response.data) {
|
||||
// Convert arraybuffer to base64
|
||||
const base64 = Buffer.from(response.data as ArrayBuffer).toString("base64");
|
||||
setImageUri(`data:image/jpeg;base64,${base64}`);
|
||||
} else {
|
||||
setImageError(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setImageError(true);
|
||||
} finally {
|
||||
setImageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage();
|
||||
}, [person?.personal_id]);
|
||||
|
||||
const themedStyles = {
|
||||
card: {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.separator,
|
||||
},
|
||||
name: {
|
||||
color: colors.text,
|
||||
},
|
||||
role: {
|
||||
color: colors.primary,
|
||||
backgroundColor: colors.primary + "20",
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
value: {
|
||||
color: colors.text,
|
||||
},
|
||||
iconColor: colors.textSecondary,
|
||||
imagePlaceholder: {
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.card, themedStyles.card]}>
|
||||
{/* Left Image Section (1/3 width) */}
|
||||
<View style={[styles.imageSection, themedStyles.imagePlaceholder]}>
|
||||
{imageLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : imageUri && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.crewImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.imagePlaceholder}>
|
||||
<Ionicons name="person" size={40} color={colors.textSecondary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right Content Section (2/3 width) */}
|
||||
<View style={styles.contentSection}>
|
||||
{/* Name & Role */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.name, themedStyles.name]} numberOfLines={1}>
|
||||
{person?.name || "-"}
|
||||
</Text>
|
||||
<View style={[styles.roleBadge, { backgroundColor: themedStyles.role.backgroundColor }]}>
|
||||
<Text style={[styles.roleText, { color: themedStyles.role.color }]}>
|
||||
{crew.role || t("diary.crew.member")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info Grid */}
|
||||
<View style={styles.infoGrid}>
|
||||
{/* Phone */}
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name="call-outline" size={14} color={themedStyles.iconColor} />
|
||||
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
||||
{person?.phone || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Personal ID */}
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name="card-outline" size={14} color={themedStyles.iconColor} />
|
||||
<Text style={[styles.value, themedStyles.value]} numberOfLines={1}>
|
||||
{person?.personal_id || "-"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Joined Date */}
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name="calendar-outline" size={14} color={themedStyles.iconColor} />
|
||||
<Text style={[styles.value, themedStyles.value]}>{joinedDate}</Text>
|
||||
</View>
|
||||
|
||||
{/* Left Date (only show if exists) */}
|
||||
{leftDate && (
|
||||
<View style={styles.infoRow}>
|
||||
<Ionicons name="exit-outline" size={14} color={themedStyles.iconColor} />
|
||||
<Text style={[styles.value, themedStyles.value]}>{leftDate}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{(onEdit || onDelete) && (
|
||||
<View style={styles.actionRow}>
|
||||
{onEdit && (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: colors.primary + "15" }]}
|
||||
onPress={() => onEdit(crew)}
|
||||
>
|
||||
<Ionicons name="pencil-outline" size={14} color={colors.primary} />
|
||||
<Text style={[styles.actionText, { color: colors.primary }]}>
|
||||
{t("common.edit")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onDelete && (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: (colors.error || "#FF3B30") + "15" }]}
|
||||
onPress={() => onDelete(crew)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={14} color={colors.error || "#FF3B30"} />
|
||||
<Text style={[styles.actionText, { color: colors.error || "#FF3B30" }]}>
|
||||
{t("common.delete")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: "row",
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
overflow: "hidden",
|
||||
maxHeight: 150,
|
||||
},
|
||||
imageSection: {
|
||||
width: 130,
|
||||
alignSelf: "stretch",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
crewImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
imagePlaceholder: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
},
|
||||
contentSection: {
|
||||
flex: 1,
|
||||
padding: 10,
|
||||
justifyContent: "center",
|
||||
},
|
||||
header: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
marginBottom: 4,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
roleBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
roleText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
infoGrid: {
|
||||
gap: 4,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
flex: 1,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
},
|
||||
actionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
69
components/diary/TripCrewModal/CrewList.tsx
Normal file
69
components/diary/TripCrewModal/CrewList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
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";
|
||||
|
||||
interface CrewListProps {
|
||||
crews: Model.TripCrews[];
|
||||
onEdit?: (crew: Model.TripCrews) => void;
|
||||
onDelete?: (crew: Model.TripCrews) => void;
|
||||
}
|
||||
|
||||
export default function CrewList({ crews, onEdit, onDelete }: CrewListProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const renderItem = ({ item }: { item: Model.TripCrews }) => (
|
||||
<CrewCard crew={item} onEdit={onEdit} onDelete={onDelete} />
|
||||
);
|
||||
|
||||
const keyExtractor = (item: Model.TripCrews, index: number) =>
|
||||
`${item.PersonalID}-${index}`;
|
||||
|
||||
const renderEmpty = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
|
||||
{t("diary.crew.noCrewMembers")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={crews}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 20,
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontFamily: Platform.select({
|
||||
ios: "System",
|
||||
android: "Roboto",
|
||||
default: "System",
|
||||
}),
|
||||
},
|
||||
});
|
||||
377
components/diary/TripCrewModal/index.tsx
Normal file
377
components/diary/TripCrewModal/index.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { queryTripCrew } from "@/controller/TripCrewController";
|
||||
import CrewList from "./CrewList";
|
||||
import AddEditCrewModal from "./AddEditCrewModal";
|
||||
|
||||
interface TripCrewModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
tripId: string | null;
|
||||
tripName?: string;
|
||||
}
|
||||
|
||||
export default function TripCrewModal({
|
||||
visible,
|
||||
onClose,
|
||||
tripId,
|
||||
tripName,
|
||||
}: TripCrewModalProps) {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
// Animation values
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
|
||||
|
||||
// State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Add/Edit modal state
|
||||
const [showAddEditModal, setShowAddEditModal] = useState(false);
|
||||
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(null);
|
||||
|
||||
// Fetch crew data when modal opens
|
||||
useEffect(() => {
|
||||
if (visible && tripId) {
|
||||
fetchCrewData();
|
||||
}
|
||||
}, [visible, tripId]);
|
||||
|
||||
// Handle animation when modal visibility changes
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setError(null);
|
||||
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 fetchCrewData = async () => {
|
||||
if (!tripId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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)) {
|
||||
setCrews(data);
|
||||
} else {
|
||||
setCrews([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching crew:", err);
|
||||
setError(t("diary.crew.fetchError"));
|
||||
setCrews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
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();
|
||||
setTimeout(() => {
|
||||
setCrews([]);
|
||||
setError(null);
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
// Add crew handler
|
||||
const handleAddCrew = () => {
|
||||
setEditingCrew(null);
|
||||
setShowAddEditModal(true);
|
||||
};
|
||||
|
||||
// Edit crew handler
|
||||
const handleEditCrew = (crew: Model.TripCrews) => {
|
||||
setEditingCrew(crew);
|
||||
setShowAddEditModal(true);
|
||||
};
|
||||
|
||||
// Delete crew handler
|
||||
const handleDeleteCrew = (crew: Model.TripCrews) => {
|
||||
Alert.alert(
|
||||
t("diary.crew.deleteConfirmTitle"),
|
||||
t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
// TODO: Call delete API when available
|
||||
// For now, just remove from local state
|
||||
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
|
||||
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 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
|
||||
await fetchCrewData();
|
||||
};
|
||||
|
||||
const themedStyles = {
|
||||
modalContainer: { backgroundColor: colors.card },
|
||||
header: { borderBottomColor: colors.separator },
|
||||
title: { color: colors.text },
|
||||
subtitle: { color: colors.textSecondary },
|
||||
content: { backgroundColor: colors.background },
|
||||
};
|
||||
|
||||
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>
|
||||
<View style={styles.headerTitles}>
|
||||
<Text style={[styles.title, themedStyles.title]}>
|
||||
{t("diary.crew.title")}
|
||||
</Text>
|
||||
{tripName && (
|
||||
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
|
||||
{tripName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={[styles.content, themedStyles.content]}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<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" }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: colors.primary }]}
|
||||
onPress={fetchCrewData}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<CrewList
|
||||
crews={crews}
|
||||
onEdit={handleEditCrew}
|
||||
onDelete={handleDeleteCrew}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Footer - Crew count */}
|
||||
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
|
||||
<View style={styles.countContainer}>
|
||||
<Ionicons name="people-outline" size={20} color={colors.primary} />
|
||||
<Text style={[styles.countText, { color: colors.text }]}>
|
||||
{t("diary.crew.totalMembers", { count: crews.length })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
|
||||
{/* Add/Edit Crew Modal */}
|
||||
<AddEditCrewModal
|
||||
visible={showAddEditModal}
|
||||
onClose={() => {
|
||||
setShowAddEditModal(false);
|
||||
setEditingCrew(null);
|
||||
}}
|
||||
onSave={handleSaveCrew}
|
||||
mode={editingCrew ? "edit" : "add"}
|
||||
initialData={
|
||||
editingCrew
|
||||
? {
|
||||
personalId: editingCrew.PersonalID,
|
||||
name: editingCrew.Person?.name || "",
|
||||
phone: editingCrew.Person?.phone || "",
|
||||
email: editingCrew.Person?.email || "",
|
||||
address: editingCrew.Person?.address || "",
|
||||
role: editingCrew.role || "crew",
|
||||
note: editingCrew.note || "",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
modalContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: "92%",
|
||||
minHeight: "80%",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
addButton: {
|
||||
padding: 4,
|
||||
},
|
||||
headerTitles: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: "center",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: "#FFFFFF",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
countContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
countText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user