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:
2025-12-23 23:10:19 +07:00
parent afc6acbfe2
commit 000a4ed856
22 changed files with 3221 additions and 379 deletions

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

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

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

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