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

@@ -9,13 +9,13 @@ import {
import { Ionicons } from "@expo/vector-icons";
import { useTripStatusConfig } from "./types";
import { useThings } from "@/state/use-thing";
import { useShip } from "@/state/use-ship";
import dayjs from "dayjs";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripCardProps {
trip: Model.Trip;
onPress?: () => void;
onView?: () => void;
onEdit?: () => void;
onTeam?: () => void;
@@ -25,7 +25,6 @@ interface TripCardProps {
export default function TripCard({
trip,
onPress,
onView,
onEdit,
onTeam,
@@ -35,6 +34,7 @@ export default function TripCard({
const { t } = useI18n();
const { colors } = useThemeContext();
const { things } = useThings();
const { ships } = useShip();
const TRIP_STATUS_CONFIG = useTripStatusConfig();
// Tìm thing có id trùng với vms_id của trip
@@ -42,6 +42,9 @@ export default function TripCard({
(thing) => thing.id === trip.vms_id
);
// Tìm ship để lấy reg_number
const shipOfTrip = ships?.find((s) => s.id === trip.ship_id);
// Lấy config status từ trip_status (number)
const statusKey = trip.trip_status as keyof typeof TRIP_STATUS_CONFIG;
const statusConfig = TRIP_STATUS_CONFIG[statusKey] || {
@@ -54,7 +57,7 @@ export default function TripCard({
// Determine which actions to show based on status
const showEdit = trip.trip_status === 0 || trip.trip_status === 1;
const showSend = trip.trip_status === 0;
const showDelete = trip.trip_status === 1;
const showDelete = trip.trip_status === 1 || trip.trip_status === 2;
const themedStyles = {
card: {
@@ -90,7 +93,9 @@ export default function TripCard({
color={statusConfig.textColor}
/>
<View style={styles.titleContainer}>
<Text style={[styles.title, themedStyles.title]}>{trip.name}</Text>
<Text style={[styles.title, themedStyles.title]}>
{trip.name}
</Text>
</View>
</View>
<View
@@ -117,14 +122,27 @@ export default function TripCard({
{/* Info Grid */}
<View style={styles.infoGrid}>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipName")}</Text>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.shipName")}
</Text>
<Text style={[styles.value, themedStyles.value]}>
{thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.departure")}</Text>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.shipCode")}
</Text>
<Text style={[styles.value, themedStyles.value]}>
{shipOfTrip?.reg_number || "-"}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.departure")}
</Text>
<Text style={[styles.value, themedStyles.value]}>
{trip.departure_time
? dayjs(trip.departure_time).format("DD/MM/YYYY HH:mm")
@@ -133,7 +151,9 @@ export default function TripCard({
</View>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.return")}</Text>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripCard.return")}
</Text>
{/* FIXME: trip.returnDate không có trong dữ liệu API, cần mapping từ trip.arrival_time */}
<Text style={[styles.value, themedStyles.value]}>
{trip.arrival_time
@@ -153,7 +173,9 @@ export default function TripCard({
activeOpacity={0.7}
>
<Ionicons name="eye-outline" size={20} color={colors.textSecondary} />
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.view")}</Text>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.view")}
</Text>
</TouchableOpacity>
{showEdit && (
@@ -162,8 +184,14 @@ export default function TripCard({
onPress={onEdit}
activeOpacity={0.7}
>
<Ionicons name="create-outline" size={20} color={colors.textSecondary} />
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.edit")}</Text>
<Ionicons
name="create-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.edit")}
</Text>
</TouchableOpacity>
)}
@@ -172,8 +200,14 @@ export default function TripCard({
onPress={onTeam}
activeOpacity={0.7}
>
<Ionicons name="people-outline" size={20} color={colors.textSecondary} />
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.team")}</Text>
<Ionicons
name="people-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.team")}
</Text>
</TouchableOpacity>
{showSend && (
@@ -182,8 +216,14 @@ export default function TripCard({
onPress={onSend}
activeOpacity={0.7}
>
<Ionicons name="send-outline" size={20} color={colors.textSecondary} />
<Text style={[styles.actionText, themedStyles.actionText]}>{t("diary.tripCard.send")}</Text>
<Ionicons
name="send-outline"
size={20}
color={colors.textSecondary}
/>
<Text style={[styles.actionText, themedStyles.actionText]}>
{t("diary.tripCard.send")}
</Text>
</TouchableOpacity>
)}
@@ -194,7 +234,9 @@ export default function TripCard({
activeOpacity={0.7}
>
<Ionicons name="trash-outline" size={20} color={colors.error} />
<Text style={[styles.actionText, styles.deleteText]}>{t("diary.tripCard.delete")}</Text>
<Text style={[styles.actionText, styles.deleteText]}>
{t("diary.tripCard.delete")}
</Text>
</TouchableOpacity>
)}
</View>

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

View File

@@ -14,15 +14,32 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
const { colors } = useThemeContext();
const getAlertLevelColor = (level?: number) => {
const isDark =
colors.background === "#1C1C1E" ||
colors.background === "#000000" ||
colors.background?.toLowerCase().includes("1c1c1e");
switch (level) {
case 0:
return { bg: "#FEF3C7", text: "#92400E" }; // Warning - Yellow
case 1:
return { bg: "#FEE2E2", text: "#991B1B" }; // Error - Red
case 2:
return { bg: "#DBEAFE", text: "#1E40AF" }; // Info - Blue
default:
return { bg: "#F3F4F6", text: "#4B5563" }; // Default - Gray
case 0: // Bình thường - Blue/Info
return isDark
? { bg: "#172554", text: "#93C5FD" } // Dark blue
: { bg: "#DBEAFE", text: "#1E40AF" };
case 1: // Warning - Yellow
return isDark
? { bg: "#422006", text: "#FCD34D" } // Dark amber
: { bg: "#FEF3C7", text: "#92400E" };
case 2: // Error - Red
return isDark
? { bg: "#450A0A", text: "#FCA5A5" } // Dark red
: { bg: "#FEE2E2", text: "#991B1B" };
case 3: // SOS - Critical Red
return isDark
? { bg: "#7F1D1D", text: "#FFFFFF" } // Dark critical
: { bg: "#DC2626", text: "#FFFFFF" };
default: // Default - Gray
return isDark
? { bg: "#374151", text: "#D1D5DB" } // Dark gray
: { bg: "#F3F4F6", text: "#4B5563" };
}
};
@@ -38,11 +55,16 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
icon="warning-outline"
count={alerts.length}
collapsible
defaultExpanded={alerts.length > 0}
//defaultExpanded={alerts.length > 0}
defaultExpanded
>
{alerts.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="checkmark-circle-outline" size={40} color={colors.success || "#22C55E"} />
<Ionicons
name="checkmark-circle-outline"
size={40}
color={colors.success || "#22C55E"}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noAlerts")}
</Text>
@@ -63,13 +85,22 @@ export default function AlertsSection({ alerts = [] }: AlertsSectionProps) {
size={18}
color={levelColor.text}
/>
<Text style={[styles.alertName, { color: levelColor.text }]}>
<Text
style={[styles.alertName, { color: levelColor.text }]}
>
{alert.name || t("diary.tripDetail.unknownAlert")}
</Text>
</View>
{alert.confirmed && (
<View style={[styles.confirmedBadge, { backgroundColor: colors.success || "#22C55E" }]}>
<Text style={styles.confirmedText}>{t("diary.tripDetail.confirmed")}</Text>
<View
style={[
styles.confirmedBadge,
{ backgroundColor: colors.success || "#22C55E" },
]}
>
<Text style={styles.confirmedText}>
{t("diary.tripDetail.confirmed")}
</Text>
</View>
)}
</View>

View File

@@ -1,8 +1,12 @@
import React from "react";
import React, { useEffect, useMemo } from "react";
import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useShip } from "@/state/use-ship";
import { usePort } from "@/state/use-ports";
import { useGroup } from "@/state/use-group";
import { filterPortsByProvinceCode } from "@/utils/tripDataConverters";
interface BasicInfoSectionProps {
trip: Model.Trip;
@@ -15,6 +19,55 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Get data from zustand stores
const { ships, getShip } = useShip();
const { ports, getPorts } = usePort();
const { groups, getUserGroups } = useGroup();
// Fetch data if not available
useEffect(() => {
if (!ships) {
getShip();
}
}, [ships, getShip]);
useEffect(() => {
if (!ports) {
getPorts();
}
}, [ports, getPorts]);
useEffect(() => {
if (!groups) {
getUserGroups();
}
}, [groups, getUserGroups]);
// Filter ports by province codes from groups
const filteredPorts = useMemo(() => {
return filterPortsByProvinceCode(ports, groups);
}, [ports, groups]);
// Get ship name by ship_id
const shipName = useMemo(() => {
if (!trip?.ship_id || !ships) return "--";
const ship = ships.find((s) => s.id === trip.ship_id);
return ship?.name || "--";
}, [trip?.ship_id, ships]);
// Get ship code (reg_number) by ship_id
const shipCode = useMemo(() => {
if (!trip?.ship_id || !ships) return "--";
const ship = ships.find((s) => s.id === trip.ship_id);
return ship?.reg_number || "--";
}, [trip?.ship_id, ships]);
// Get port name by ID
const getPortName = (portId: number): string => {
const port = filteredPorts.find((p) => p.id === portId);
return port?.name || "--";
};
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "--";
const date = new Date(dateStr);
@@ -30,8 +83,13 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
const infoItems = [
{
icon: "boat" as const,
label: t("diary.tripDetail.shipId"),
value: trip.vms_id || "--",
label: t("diary.tripDetail.shipName"),
value: shipName,
},
{
icon: "barcode" as const,
label: t("diary.tripDetail.shipCode"),
value: shipCode,
},
{
icon: "play-circle" as const,
@@ -46,26 +104,36 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{
icon: "location" as const,
label: t("diary.tripDetail.departurePort"),
value: trip.departure_port_id ? `Cảng #${trip.departure_port_id}` : "--",
value: getPortName(trip.departure_port_id),
},
{
icon: "flag" as const,
label: t("diary.tripDetail.arrivalPort"),
value: trip.arrival_port_id ? `Cảng #${trip.arrival_port_id}` : "--",
value: getPortName(trip.arrival_port_id),
},
{
icon: "map" as const,
label: t("diary.tripDetail.fishingGrounds"),
value: trip.fishing_ground_codes?.length > 0
? trip.fishing_ground_codes.join(", ")
: "--",
value:
trip.fishing_ground_codes?.length > 0
? trip.fishing_ground_codes.join(", ")
: "--",
},
];
return (
<View style={[styles.container, { backgroundColor: colors.card, borderColor: colors.separator }]}>
<View
style={[
styles.container,
{ backgroundColor: colors.card, borderColor: colors.separator },
]}
>
<View style={styles.header}>
<Ionicons name="information-circle-outline" size={20} color={colors.primary} />
<Ionicons
name="information-circle-outline"
size={20}
color={colors.primary}
/>
<Text style={[styles.title, { color: colors.text }]}>
{t("diary.tripDetail.basicInfo")}
</Text>
@@ -74,8 +142,14 @@ export default function BasicInfoSection({ trip }: BasicInfoSectionProps) {
{infoItems.map((item, index) => (
<View key={index} style={styles.infoItem}>
<View style={styles.infoLabel}>
<Ionicons name={item.icon} size={16} color={colors.textSecondary} />
<Text style={[styles.infoLabelText, { color: colors.textSecondary }]}>
<Ionicons
name={item.icon}
size={16}
color={colors.textSecondary}
/>
<Text
style={[styles.infoLabelText, { color: colors.textSecondary }]}
>
{item.label}
</Text>
</View>
@@ -106,7 +180,11 @@ const styles = StyleSheet.create({
title: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
content: {
paddingHorizontal: 16,
@@ -125,11 +203,19 @@ const styles = StyleSheet.create({
},
infoLabelText: {
fontSize: 13,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
infoValue: {
fontSize: 13,
fontWeight: "500",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -9,7 +9,9 @@ interface FishingLogsSectionProps {
fishingLogs?: Model.FishingLog[] | null;
}
export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSectionProps) {
export default function FishingLogsSection({
fishingLogs = [],
}: FishingLogsSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
@@ -35,13 +37,29 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
const getStatusLabel = (status?: number) => {
switch (status) {
case 0:
return { label: t("diary.tripDetail.logStatusPending"), color: "#FEF3C7", textColor: "#92400E" };
return {
label: t("diary.tripDetail.logStatusProcessing"),
color: "#FEF3C7",
textColor: "#92400E",
};
case 1:
return { label: t("diary.tripDetail.logStatusActive"), color: "#DBEAFE", textColor: "#1E40AF" };
return {
label: t("diary.tripDetail.logStatusSuccess"),
color: "#D1FAE5",
textColor: "#065F46",
};
case 2:
return { label: t("diary.tripDetail.logStatusCompleted"), color: "#D1FAE5", textColor: "#065F46" };
return {
label: t("diary.tripDetail.logStatusCancelled"),
color: "#FEE2E2",
textColor: "#B91C1C",
};
default:
return { label: t("diary.tripDetail.logStatusUnknown"), color: "#F3F4F6", textColor: "#4B5563" };
return {
label: t("diary.tripDetail.logStatusUnknown"),
color: "#F3F4F6",
textColor: "#4B5563",
};
}
};
@@ -55,7 +73,11 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
>
{logs.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="fish-outline" size={40} color={colors.textSecondary} />
<Ionicons
name="fish-outline"
size={40}
color={colors.textSecondary}
/>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.noFishingLogs")}
</Text>
@@ -65,7 +87,7 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{logs.map((log, index) => {
const status = getStatusLabel(log.status);
const catchCount = log.info?.length || 0;
return (
<View
key={log.fishing_log_id || index}
@@ -74,12 +96,21 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Header */}
<View style={styles.logHeader}>
<View style={styles.logIndex}>
<Text style={[styles.logIndexText, { color: colors.primary }]}>
<Text
style={[styles.logIndexText, { color: colors.primary }]}
>
#{index + 1}
</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: status.color }]}>
<Text style={[styles.statusText, { color: status.textColor }]}>
<View
style={[
styles.statusBadge,
{ backgroundColor: status.color },
]}
>
<Text
style={[styles.statusText, { color: status.textColor }]}
>
{status.label}
</Text>
</View>
@@ -88,8 +119,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Time Info */}
<View style={styles.timeRow}>
<View style={styles.timeItem}>
<Ionicons name="play-circle-outline" size={16} color={colors.success || "#22C55E"} />
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}>
<Ionicons
name="play-circle-outline"
size={16}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
@@ -97,8 +137,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
</Text>
</View>
<View style={styles.timeItem}>
<Ionicons name="stop-circle-outline" size={16} color={colors.error || "#EF4444"} />
<Text style={[styles.timeLabel, { color: colors.textSecondary }]}>
<Ionicons
name="stop-circle-outline"
size={16}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.timeLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.endTime")}:
</Text>
<Text style={[styles.timeValue, { color: colors.text }]}>
@@ -108,22 +157,49 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
</View>
{/* Location Info */}
<View style={[styles.locationContainer, { backgroundColor: colors.backgroundSecondary }]}>
<View
style={[
styles.locationContainer,
{ backgroundColor: colors.backgroundSecondary },
]}
>
<View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.success || "#22C55E"} />
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}>
<Ionicons
name="location"
size={14}
color={colors.success || "#22C55E"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.startLocation")}:
</Text>
<Text style={[styles.locationValue, { color: colors.text }]}>
<Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.start_lat, log.start_lon)}
</Text>
</View>
<View style={styles.locationItem}>
<Ionicons name="location" size={14} color={colors.error || "#EF4444"} />
<Text style={[styles.locationLabel, { color: colors.textSecondary }]}>
<Ionicons
name="location"
size={14}
color={colors.error || "#EF4444"}
/>
<Text
style={[
styles.locationLabel,
{ color: colors.textSecondary },
]}
>
{t("diary.tripDetail.haulLocation")}:
</Text>
<Text style={[styles.locationValue, { color: colors.text }]}>
<Text
style={[styles.locationValue, { color: colors.text }]}
>
{formatCoord(log.haul_lat, log.haul_lon)}
</Text>
</View>
@@ -135,22 +211,33 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
<View style={styles.catchHeader}>
<Ionicons name="fish" size={16} color={colors.primary} />
<Text style={[styles.catchLabel, { color: colors.text }]}>
{t("diary.tripDetail.catchInfo")} ({catchCount} {t("diary.tripDetail.species")})
{t("diary.tripDetail.catchInfo")} ({catchCount}{" "}
{t("diary.tripDetail.species")})
</Text>
</View>
<View style={styles.catchList}>
{log.info?.slice(0, 3).map((fish, fishIndex) => (
<View key={fishIndex} style={styles.catchItem}>
<Text style={[styles.fishName, { color: colors.text }]}>
{fish.fish_name || t("diary.tripDetail.unknownFish")}
<Text
style={[styles.fishName, { color: colors.text }]}
>
{fish.fish_name ||
t("diary.tripDetail.unknownFish")}
</Text>
<Text style={[styles.fishAmount, { color: colors.textSecondary }]}>
<Text
style={[
styles.fishAmount,
{ color: colors.textSecondary },
]}
>
{fish.catch_number} {fish.catch_unit}
</Text>
</View>
))}
{catchCount > 3 && (
<Text style={[styles.moreText, { color: colors.primary }]}>
<Text
style={[styles.moreText, { color: colors.primary }]}
>
+{catchCount - 3} {t("diary.tripDetail.more")}
</Text>
)}
@@ -161,8 +248,17 @@ export default function FishingLogsSection({ fishingLogs = [] }: FishingLogsSect
{/* Weather */}
{log.weather_description && (
<View style={styles.weatherRow}>
<Ionicons name="cloudy-outline" size={14} color={colors.textSecondary} />
<Text style={[styles.weatherText, { color: colors.textSecondary }]}>
<Ionicons
name="cloudy-outline"
size={14}
color={colors.textSecondary}
/>
<Text
style={[
styles.weatherText,
{ color: colors.textSecondary },
]}
>
{log.weather_description}
</Text>
</View>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
View,
Text,
@@ -20,6 +20,8 @@ interface TripDurationPickerProps {
disabled?: boolean;
}
type PickerType = "startDate" | "startTime" | "endDate" | "endTime" | null;
export default function TripDurationPicker({
startDate,
endDate,
@@ -27,15 +29,27 @@ export default function TripDurationPicker({
onEndDateChange,
disabled = false,
}: TripDurationPickerProps) {
const { t } = useI18n();
const { t, locale } = useI18n();
const { colors, colorScheme } = useThemeContext();
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
// Single state for which picker is showing
const [activePicker, setActivePicker] = useState<PickerType>(null);
// Temp states to hold the picker value before confirming
const [tempStartDate, setTempStartDate] = useState<Date>(new Date());
const [tempEndDate, setTempEndDate] = useState<Date>(new Date());
// State hiển thị thời gian hiện tại
const [currentTime, setCurrentTime] = useState<Date>(new Date());
// Update current time every second
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
return () => clearInterval(timer);
}, []);
const formatDate = (date: Date | null) => {
if (!date) return "";
const day = date.getDate().toString().padStart(2, "0");
@@ -44,62 +58,102 @@ export default function TripDurationPicker({
return `${day}/${month}/${year}`;
};
const handleOpenStartPicker = () => {
const formatTime = (date: Date | null) => {
if (!date) return "";
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
// Open start date picker
const handleOpenStartDatePicker = () => {
const today = new Date();
const dateToUse = startDate || today;
// If no date selected, immediately set to today
if (!startDate) {
onStartDateChange(today);
}
// Always set tempStartDate to the date we're using (today if no date was selected)
setTempStartDate(dateToUse);
setShowStartPicker(true);
setActivePicker("startDate");
};
const handleOpenEndPicker = () => {
// Open start time picker
const handleOpenStartTimePicker = () => {
const today = new Date();
const dateToUse = startDate || today;
if (!startDate) {
onStartDateChange(today);
}
setTempStartDate(dateToUse);
setActivePicker("startTime");
};
// Open end date picker
const handleOpenEndDatePicker = () => {
const today = new Date();
const dateToUse = endDate || today;
// If no date selected, immediately set to today
if (!endDate) {
onEndDateChange(today);
}
// Always set tempEndDate to the date we're using (today if no date was selected)
setTempEndDate(dateToUse);
setShowEndPicker(true);
setActivePicker("endDate");
};
const handleStartDateChange = (event: any, selectedDate?: Date) => {
// Open end time picker
const handleOpenEndTimePicker = () => {
const today = new Date();
const dateToUse = endDate || today;
if (!endDate) {
onEndDateChange(today);
}
setTempEndDate(dateToUse);
setActivePicker("endTime");
};
const handleStartPickerChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") {
setShowStartPicker(false);
setActivePicker(null);
if (event.type === "set" && selectedDate) {
onStartDateChange(selectedDate);
}
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempStartDate(selectedDate);
onStartDateChange(selectedDate);
}
};
const handleEndDateChange = (event: any, selectedDate?: Date) => {
const handleEndPickerChange = (event: any, selectedDate?: Date) => {
if (Platform.OS === "android") {
setShowEndPicker(false);
setActivePicker(null);
if (event.type === "set" && selectedDate) {
onEndDateChange(selectedDate);
}
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempEndDate(selectedDate);
onEndDateChange(selectedDate);
}
};
const handleConfirmStartDate = () => {
setShowStartPicker(false);
const handleConfirm = () => {
setActivePicker(null);
};
const handleConfirmEndDate = () => {
setShowEndPicker(false);
const handleCancel = () => {
setActivePicker(null);
};
const getPickerTitle = () => {
switch (activePicker) {
case "startDate":
return t("diary.selectStartDate");
case "startTime":
return t("diary.selectStartTime") || "Chọn giờ khởi hành";
case "endDate":
return t("diary.selectEndDate");
case "endTime":
return t("diary.selectEndTime") || "Chọn giờ kết thúc";
default:
return "";
}
};
const themedStyles = {
@@ -114,133 +168,230 @@ export default function TripDurationPicker({
pickerHeader: { borderBottomColor: colors.border },
pickerTitle: { color: colors.text },
cancelButton: { color: colors.textSecondary },
sectionCard: {
backgroundColor: colors.backgroundSecondary || colors.card,
borderColor: colors.border,
},
sectionTitle: { color: colors.text },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripDuration")}
</Text>
<View style={styles.dateRangeContainer}>
{/* Start Date */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.startDate")}
const renderDateTimeSection = (
type: "start" | "end",
date: Date | null,
onOpenDate: () => void,
onOpenTime: () => void
) => {
const isStart = type === "start";
const icon = isStart ? "boat-outline" : "flag-outline";
const title = isStart ? t("diary.startDate") : t("diary.endDate");
const dateLabel = t("diary.date") || "Ngày";
const timeLabel = t("diary.time") || "Giờ";
return (
<View style={[styles.sectionCard, themedStyles.sectionCard]}>
{/* Section Header */}
<View style={styles.sectionHeader}>
<View
style={[
styles.iconContainer,
{ backgroundColor: isStart ? "#3B82F620" : "#10B98120" },
]}
>
<Ionicons
name={icon as any}
size={18}
color={isStart ? "#3B82F6" : "#10B981"}
/>
</View>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{title}
</Text>
</View>
{/* Date and Time Row */}
<View style={styles.dateTimeRow}>
{/* Date Picker */}
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={disabled ? undefined : handleOpenStartPicker}
style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : onOpenDate}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!startDate && themedStyles.placeholder,
]}
>
{startDate ? formatDate(startDate) : t("diary.selectDate")}
</Text>
{!disabled && (
<View style={styles.inputContent}>
<Ionicons
name="calendar-outline"
size={20}
size={18}
color={date ? colors.primary : colors.textSecondary}
style={styles.inputIcon}
/>
<View style={styles.inputTextContainer}>
<Text style={[styles.inputLabel, themedStyles.placeholder]}>
{dateLabel}
</Text>
<Text
style={[
styles.inputValue,
themedStyles.dateText,
!date && themedStyles.placeholder,
]}
>
{date ? formatDate(date) : t("diary.selectDate")}
</Text>
</View>
</View>
{!disabled && (
<Ionicons
name="chevron-forward"
size={16}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
</View>
{/* End Date */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.endDate")}
</Text>
{/* Time Picker */}
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={disabled ? undefined : handleOpenEndPicker}
style={[styles.dateTimeInput, themedStyles.dateInput]}
onPress={disabled ? undefined : onOpenTime}
activeOpacity={disabled ? 1 : 0.7}
disabled={disabled}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!endDate && themedStyles.placeholder,
]}
>
{endDate ? formatDate(endDate) : t("diary.selectDate")}
</Text>
<View style={styles.inputContent}>
<Ionicons
name="time-outline"
size={18}
color={date ? colors.primary : colors.textSecondary}
style={styles.inputIcon}
/>
<View style={styles.inputTextContainer}>
<Text style={[styles.inputLabel, themedStyles.placeholder]}>
{timeLabel}
</Text>
<Text
style={[
styles.inputValue,
themedStyles.dateText,
!date && themedStyles.placeholder,
]}
>
{date ? formatTime(date) : "--:--"}
</Text>
</View>
</View>
{!disabled && (
<Ionicons
name="calendar-outline"
size={20}
name="chevron-forward"
size={16}
color={colors.textSecondary}
/>
)}
</TouchableOpacity>
</View>
</View>
);
};
{/* Start Date Picker */}
{showStartPicker && (
<Modal transparent animationType="fade" visible={showStartPicker}>
<View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowStartPicker(false)}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectStartDate")}
</Text>
<TouchableOpacity onPress={handleConfirmStartDate}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={tempStartDate}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleStartDateChange}
maximumDate={endDate || undefined}
themeVariant={colorScheme}
textColor={colors.text}
/>
</View>
</View>
</Modal>
const isStartPicker =
activePicker === "startDate" || activePicker === "startTime";
const isTimePicker =
activePicker === "startTime" || activePicker === "endTime";
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripDuration")}
</Text>
{/* Hiển thị thời gian hiện tại */}
<View
style={[
styles.currentTimeContainer,
{ backgroundColor: colors.backgroundSecondary || colors.card },
]}
>
<Ionicons name="time-outline" size={18} color={colors.primary} />
<Text
style={[styles.currentTimeLabel, { color: colors.textSecondary }]}
>
{t("diary.currentTime") || "Thời gian hiện tại"}:
</Text>
<Text style={[styles.currentTimeValue, { color: colors.primary }]}>
{formatDate(currentTime)} {formatTime(currentTime)}
</Text>
</View>
{/* Start Section */}
{renderDateTimeSection(
"start",
startDate,
handleOpenStartDatePicker,
handleOpenStartTimePicker
)}
{/* End Date Picker */}
{showEndPicker && (
<Modal transparent animationType="fade" visible={showEndPicker}>
{/* Connection Line */}
<View style={styles.connectionContainer}>
<View
style={[styles.connectionLine, { backgroundColor: colors.border }]}
/>
<View
style={[styles.connectionDot, { backgroundColor: colors.primary }]}
/>
<View
style={[styles.connectionLine, { backgroundColor: colors.border }]}
/>
</View>
{/* End Section */}
{renderDateTimeSection(
"end",
endDate,
handleOpenEndDatePicker,
handleOpenEndTimePicker
)}
{/* Unified Picker Modal */}
{activePicker && (
<Modal transparent animationType="fade" visible={!!activePicker}>
<View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View
style={[styles.pickerContainer, themedStyles.pickerContainer]}
>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
<TouchableOpacity onPress={handleCancel}>
<Text
style={[styles.cancelButtonText, themedStyles.cancelButton]}
>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectEndDate")}
{getPickerTitle()}
</Text>
<TouchableOpacity onPress={handleConfirmEndDate}>
<TouchableOpacity onPress={handleConfirm}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={tempEndDate}
mode="date"
value={isStartPicker ? tempStartDate : tempEndDate}
mode={isTimePicker ? "time" : "date"}
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleEndDateChange}
minimumDate={startDate || undefined}
onChange={
isStartPicker
? handleStartPickerChange
: handleEndPickerChange
}
maximumDate={
isStartPicker && !isTimePicker
? endDate || undefined
: undefined
}
minimumDate={
!isStartPicker && !isTimePicker
? startDate || undefined
: undefined
}
themeVariant={colorScheme}
textColor={colors.text}
locale={locale}
/>
</View>
</View>
@@ -257,46 +408,100 @@ const styles = StyleSheet.create({
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
marginBottom: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
sectionCard: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
},
dateRangeContainer: {
sectionHeader: {
flexDirection: "row",
gap: 12,
alignItems: "center",
marginBottom: 12,
},
dateSection: {
iconContainer: {
width: 32,
height: 32,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
sectionTitle: {
fontSize: 15,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
},
dateTimeInput: {
flex: 1,
},
dateInput: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 12,
paddingVertical: 10,
},
dateText: {
fontSize: 15,
inputContent: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
inputIcon: {
marginRight: 10,
},
inputTextContainer: {
flex: 1,
},
inputLabel: {
fontSize: 11,
marginBottom: 2,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
inputValue: {
fontSize: 14,
fontWeight: "500",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
connectionContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 8,
paddingHorizontal: 20,
},
connectionLine: {
flex: 1,
height: 1,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 8,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -324,7 +529,7 @@ const styles = StyleSheet.create({
default: "System",
}),
},
cancelButton: {
cancelButtonText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
@@ -342,4 +547,29 @@ const styles = StyleSheet.create({
default: "System",
}),
},
currentTimeContainer: {
flexDirection: "row",
alignItems: "center",
padding: 12,
borderRadius: 8,
marginBottom: 12,
gap: 8,
},
currentTimeLabel: {
fontSize: 13,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
currentTimeValue: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -227,6 +227,30 @@ export default function TripFormModal({
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return false;
}
// Validate: thời điểm khởi hành phải từ hiện tại trở đi
const now = new Date();
const startMinutes = Math.floor(startDate.getTime() / 60000);
const nowMinutes = Math.floor(now.getTime() / 60000);
if (startMinutes < nowMinutes) {
Alert.alert(
t("common.error"),
t("diary.validation.startDateNotInPast") ||
"Thời điểm khởi hành không được ở quá khứ"
);
return false;
}
// Validate: thời điểm kết thúc phải sau thời điểm khởi hành
if (endDate <= startDate) {
Alert.alert(
t("common.error"),
t("diary.validation.endDateAfterStart") ||
"Thời điểm kết thúc phải sau thời điểm khởi hành"
);
return false;
}
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return false;
@@ -237,7 +261,6 @@ export default function TripFormModal({
// Build API body
const buildApiBody = useCallback((): Model.TripAPIBody => {
return {
thing_id: selectedShipId,
name: tripName,
departure_time: startDate?.toISOString() || "",
departure_port_id: departurePortId,
@@ -257,7 +280,6 @@ export default function TripFormModal({
})),
};
}, [
selectedShipId,
tripName,
startDate,
endDate,
@@ -293,10 +315,28 @@ export default function TripFormModal({
isEditMode ? "Error updating trip:" : "Error creating trip:",
error
);
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
);
// Lấy message từ server response
const serverMessage = error.response?.data || "";
// Kiểm tra lỗi cụ thể: trip already exists
if (
serverMessage.includes &&
serverMessage.includes("already exists and not completed")
) {
// Đánh dấu lỗi đã được xử lý (axios sẽ không hiển thị toast cho status 400)
Alert.alert(
t("common.warning") || "Cảnh báo",
t("diary.tripAlreadyExistsError") ||
"Chuyến đi đang diễn ra chưa hoàn thành. Vui lòng hoàn thành chuyến đi hiện tại trước khi tạo chuyến mới."
);
} else {
// Các lỗi khác
Alert.alert(
t("common.error"),
isEditMode ? t("diary.updateTripError") : t("diary.createTripError")
);
}
} finally {
setIsSubmitting(false);
}
@@ -356,7 +396,10 @@ export default function TripFormModal({
{!isEditMode && <AutoFillSection onAutoFill={handleAutoFill} />}
{/* Section 1: Basic Information */}
<FormSection title={t("diary.formSection.basicInfo")} icon="boat-outline">
<FormSection
title={t("diary.formSection.basicInfo")}
icon="boat-outline"
>
{/* Ship Selector - disabled in edit mode */}
<ShipSelector
selectedShipId={selectedShipId}
@@ -369,7 +412,10 @@ export default function TripFormModal({
</FormSection>
{/* Section 2: Schedule & Location */}
<FormSection title={t("diary.formSection.schedule")} icon="calendar-outline">
<FormSection
title={t("diary.formSection.schedule")}
icon="calendar-outline"
>
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
@@ -394,13 +440,27 @@ export default function TripFormModal({
</FormSection>
{/* Section 3: Equipment */}
<FormSection title={t("diary.formSection.equipment")} icon="construct-outline">
<FishingGearList items={fishingGears} onChange={setFishingGears} hideTitle />
<FormSection
title={t("diary.formSection.equipment")}
icon="construct-outline"
>
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
hideTitle
/>
</FormSection>
{/* Section 4: Costs */}
<FormSection title={t("diary.formSection.costs")} icon="wallet-outline">
<MaterialCostList items={tripCosts} onChange={setTripCosts} hideTitle />
<FormSection
title={t("diary.formSection.costs")}
icon="wallet-outline"
>
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
hideTitle
/>
</FormSection>
</ScrollView>