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

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