Cập nhật API thêm trip (validate)

This commit is contained in:
2025-12-22 15:22:06 +07:00
parent 12fb7c48ed
commit 67e9fc22a3
9 changed files with 228 additions and 86 deletions

View File

@@ -37,6 +37,7 @@ export default function diary() {
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const isInitialLoad = useRef(true); const isInitialLoad = useRef(true);
const flatListRef = useRef<FlatList>(null);
// Body call API things (đang fix cứng) // Body call API things (đang fix cứng)
const payloadThings: Model.SearchThingBody = { const payloadThings: Model.SearchThingBody = {
@@ -198,6 +199,25 @@ export default function diary() {
// TODO: Show confirmation dialog and delete trip // TODO: Show confirmation dialog and delete trip
}; };
// Handle sau khi thêm chuyến đi thành công
const handleTripAddSuccess = useCallback(() => {
// Reset về trang đầu và gọi lại API
isInitialLoad.current = true;
setAllTrips([]);
setHasMore(true);
const resetPayload: Model.TripListBody = {
...payloadTrips,
offset: 0,
};
setPayloadTrips(resetPayload);
getTripsList(resetPayload);
// Scroll FlatList lên đầu
setTimeout(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 100);
}, [payloadTrips, getTripsList]);
// Dynamic styles based on theme // Dynamic styles based on theme
const themedStyles = { const themedStyles = {
safeArea: { safeArea: {
@@ -304,6 +324,7 @@ export default function diary() {
{/* Trip List with FlatList */} {/* Trip List with FlatList */}
<FlatList <FlatList
ref={flatListRef}
data={allTrips} data={allTrips}
renderItem={renderTripItem} renderItem={renderTripItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@@ -331,6 +352,7 @@ export default function diary() {
<AddTripModal <AddTripModal
visible={showAddTripModal} visible={showAddTripModal}
onClose={() => setShowAddTripModal(false)} onClose={() => setShowAddTripModal(false)}
onSuccess={handleTripAddSuccess}
/> />
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -19,7 +19,7 @@ import { queryLastTrip } from "@/controller/TripController";
import { showErrorToast } from "@/services/toast_service"; import { showErrorToast } from "@/services/toast_service";
interface AutoFillSectionProps { interface AutoFillSectionProps {
onAutoFill: (tripData: Model.Trip, selectedShipId: string) => void; onAutoFill: (tripData: Model.Trip, selectedThingId: string) => void;
} }
export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) { export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
@@ -36,7 +36,7 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
things things
?.filter((thing) => thing.id != null) ?.filter((thing) => thing.id != null)
.map((thing) => ({ .map((thing) => ({
id: thing.id as string, thingId: thing.id as string,
shipName: thing.metadata?.ship_name || "", shipName: thing.metadata?.ship_name || "",
})) || []; })) || [];
@@ -46,17 +46,17 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
return ship.shipName.toLowerCase().includes(searchLower); return ship.shipName.toLowerCase().includes(searchLower);
}); });
const handleSelectShip = async (shipId: string) => { const handleSelectShip = async (thingId: string) => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await queryLastTrip(shipId); const response = await queryLastTrip(thingId);
if (response.data) { if (response.data) {
// Close the modal first before showing alert // Close the modal first before showing alert
setIsOpen(false); setIsOpen(false);
setSearchText(""); setSearchText("");
// Pass shipId (thingId) along with trip data for filling ShipSelector // Pass thingId along with trip data for filling ShipSelector
onAutoFill(response.data, shipId); onAutoFill(response.data, thingId);
// Use Alert instead of Toast so it appears above all modals // Use Alert instead of Toast so it appears above all modals
Alert.alert( Alert.alert(
@@ -182,9 +182,9 @@ export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
{filteredShips.length > 0 ? ( {filteredShips.length > 0 ? (
filteredShips.map((ship) => ( filteredShips.map((ship) => (
<TouchableOpacity <TouchableOpacity
key={ship.id} key={ship.thingId}
style={[styles.option, themedStyles.option]} style={[styles.option, themedStyles.option]}
onPress={() => handleSelectShip(ship.id)} onPress={() => handleSelectShip(ship.thingId)}
> >
<View style={styles.optionContent}> <View style={styles.optionContent}>
<Ionicons <Ionicons

View File

@@ -30,6 +30,10 @@ export default function TripDurationPicker({
const [showStartPicker, setShowStartPicker] = useState(false); const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false); const [showEndPicker, setShowEndPicker] = useState(false);
// Temp states to hold the picker value before confirming
const [tempStartDate, setTempStartDate] = useState<Date>(new Date());
const [tempEndDate, setTempEndDate] = useState<Date>(new Date());
const formatDate = (date: Date | null) => { const formatDate = (date: Date | null) => {
if (!date) return ""; if (!date) return "";
const day = date.getDate().toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0");
@@ -38,18 +42,62 @@ export default function TripDurationPicker({
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`;
}; };
const handleOpenStartPicker = () => {
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);
};
const handleOpenEndPicker = () => {
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);
};
const handleStartDateChange = (event: any, selectedDate?: Date) => { const handleStartDateChange = (event: any, selectedDate?: Date) => {
setShowStartPicker(Platform.OS === "ios"); if (Platform.OS === "android") {
if (selectedDate) { setShowStartPicker(false);
if (event.type === "set" && selectedDate) {
onStartDateChange(selectedDate);
}
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempStartDate(selectedDate);
onStartDateChange(selectedDate); onStartDateChange(selectedDate);
} }
}; };
const handleEndDateChange = (event: any, selectedDate?: Date) => { const handleEndDateChange = (event: any, selectedDate?: Date) => {
setShowEndPicker(Platform.OS === "ios"); if (Platform.OS === "android") {
if (selectedDate) { setShowEndPicker(false);
if (event.type === "set" && selectedDate) {
onEndDateChange(selectedDate); onEndDateChange(selectedDate);
} }
} else if (selectedDate) {
// For iOS, update both temp and actual date immediately
setTempEndDate(selectedDate);
onEndDateChange(selectedDate);
}
};
const handleConfirmStartDate = () => {
setShowStartPicker(false);
};
const handleConfirmEndDate = () => {
setShowEndPicker(false);
}; };
const themedStyles = { const themedStyles = {
@@ -79,7 +127,7 @@ export default function TripDurationPicker({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]} style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowStartPicker(true)} onPress={handleOpenStartPicker}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text
@@ -106,7 +154,7 @@ export default function TripDurationPicker({
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]} style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowEndPicker(true)} onPress={handleOpenEndPicker}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text <Text
@@ -141,12 +189,12 @@ export default function TripDurationPicker({
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}> <Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectStartDate")} {t("diary.selectStartDate")}
</Text> </Text>
<TouchableOpacity onPress={() => setShowStartPicker(false)}> <TouchableOpacity onPress={handleConfirmStartDate}>
<Text style={styles.doneButton}>{t("common.done")}</Text> <Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<DateTimePicker <DateTimePicker
value={startDate || new Date()} value={tempStartDate}
mode="date" mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"} display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleStartDateChange} onChange={handleStartDateChange}
@@ -173,12 +221,12 @@ export default function TripDurationPicker({
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}> <Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectEndDate")} {t("diary.selectEndDate")}
</Text> </Text>
<TouchableOpacity onPress={() => setShowEndPicker(false)}> <TouchableOpacity onPress={handleConfirmEndDate}>
<Text style={styles.doneButton}>{t("common.done")}</Text> <Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<DateTimePicker <DateTimePicker
value={endDate || new Date()} value={tempEndDate}
mode="date" mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"} display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleEndDateChange} onChange={handleEndDateChange}

View File

@@ -7,6 +7,8 @@ import {
StyleSheet, StyleSheet,
Platform, Platform,
ScrollView, ScrollView,
Alert,
ActivityIndicator,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n"; import { useI18n } from "@/hooks/use-i18n";
@@ -19,52 +21,24 @@ import PortSelector from "@/components/diary/addTripModal/PortSelector";
import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput"; import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput";
import ShipSelector from "./ShipSelector"; import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection"; import AutoFillSection from "./AutoFillSection";
import { createTrip } from "@/controller/TripController";
// Internal component interfaces - extend from Model with local id for state management
// Internal component interfaces export interface FishingGear extends Model.FishingGear {
export interface FishingGear {
id: string; id: string;
name: string;
number: string; // Changed from quantity to number (string)
} }
export interface TripCost { export interface TripCost extends Model.TripCost {
id: string; id: string;
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}
// API body interface
export interface TripAPIBody {
thing_id?: string; // Ship ID
name: string;
departure_time: string; // ISO string
departure_port_id: number;
arrival_time: string; // ISO string
arrival_port_id: number;
fishing_ground_codes: number[]; // Array of numbers
fishing_gears: Array<{
name: string;
number: string;
}>;
trip_cost: Array<{
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}>;
} }
interface AddTripModalProps { interface AddTripModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void; // Callback khi thêm chuyến đi thành công
} }
export default function AddTripModal({ visible, onClose }: AddTripModalProps) { export default function AddTripModal({ visible, onClose, onSuccess }: AddTripModalProps) {
const { t } = useI18n(); const { t } = useI18n();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
@@ -78,6 +52,7 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
const [departurePortId, setDeparturePortId] = useState<number>(1); const [departurePortId, setDeparturePortId] = useState<number>(1);
const [arrivalPortId, setArrivalPortId] = useState<number>(1); const [arrivalPortId, setArrivalPortId] = useState<number>(1);
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // Input as string, convert to array const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // Input as string, convert to array
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCancel = () => { const handleCancel = () => {
// Reset form // Reset form
@@ -99,17 +74,19 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
setSelectedShipId(selectedThingId); setSelectedShipId(selectedThingId);
// Fill trip name // Fill trip name
if (tripData.name) { // if (tripData.name) {
setTripName(tripData.name); // setTripName(tripData.name);
} // }
// Fill fishing gears // Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) { if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({ const gears: FishingGear[] = tripData.fishing_gears.map(
(gear, index) => ({
id: `auto-${Date.now()}-${index}`, id: `auto-${Date.now()}-${index}`,
name: gear.name || "", name: gear.name || "",
number: gear.number?.toString() || "", number: gear.number?.toString() || "",
})); })
);
setFishingGears(gears); setFishingGears(gears);
} }
@@ -135,12 +112,33 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
} }
// Fill fishing ground codes // Fill fishing ground codes
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) { if (
tripData.fishing_ground_codes &&
Array.isArray(tripData.fishing_ground_codes)
) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", ")); setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
} }
}; };
const handleSubmit = () => { const handleSubmit = async () => {
// Validate thingId is required
if (!selectedShipId) {
Alert.alert(t("common.error"), t("diary.validation.shipRequired"));
return;
}
// Validate dates are required
if (!startDate || !endDate) {
Alert.alert(t("common.error"), t("diary.validation.datesRequired"));
return;
}
// Validate trip name is required
if (!tripName.trim()) {
Alert.alert(t("common.error"), t("diary.validation.tripNameRequired"));
return;
}
// Parse fishing ground codes from comma-separated string to array of numbers // Parse fishing ground codes from comma-separated string to array of numbers
const fishingGroundCodesArray = fishingGroundCodes const fishingGroundCodesArray = fishingGroundCodes
.split(",") .split(",")
@@ -148,8 +146,8 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
.filter((code) => !isNaN(code)); .filter((code) => !isNaN(code));
// Format API body // Format API body
const apiBody: TripAPIBody = { const apiBody: Model.TripAPIBody = {
thing_id: selectedShipId || undefined, thing_id: selectedShipId,
name: tripName, name: tripName,
departure_time: startDate ? startDate.toISOString() : "", departure_time: startDate ? startDate.toISOString() : "",
departure_port_id: departurePortId, departure_port_id: departurePortId,
@@ -169,13 +167,37 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
})), })),
}; };
// Simulate API call - log the formatted data setIsSubmitting(true);
console.log("=== Submitting Trip Data (API Format) ==="); try {
console.log(JSON.stringify(apiBody, null, 2)); const response = await createTrip(selectedShipId, apiBody);
console.log("=== End Trip Data ===");
if (response.data) {
// Show success alert
Alert.alert(
t("common.success"),
t("diary.createTripSuccess")
);
// Call onSuccess callback
if (onSuccess) {
onSuccess();
}
// Reset form and close modal // Reset form and close modal
handleCancel(); handleCancel();
}
} catch (error: any) {
console.error("Error creating trip:", error);
// Log detailed error information for debugging
if (error.response) {
console.error("Response status:", error.response.status);
console.error("Response data:", JSON.stringify(error.response.data, null, 2));
}
console.log("Request body was:", JSON.stringify(apiBody, null, 2));
Alert.alert(t("common.error"), t("diary.createTripError"));
} finally {
setIsSubmitting(false);
}
}; };
const themedStyles = { const themedStyles = {
@@ -240,16 +262,10 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
<TripNameInput value={tripName} onChange={setTripName} /> <TripNameInput value={tripName} onChange={setTripName} />
{/* Fishing Gear List */} {/* Fishing Gear List */}
<FishingGearList <FishingGearList items={fishingGears} onChange={setFishingGears} />
items={fishingGears}
onChange={setFishingGears}
/>
{/* Trip Cost List */} {/* Trip Cost List */}
<MaterialCostList <MaterialCostList items={tripCosts} onChange={setTripCosts} />
items={tripCosts}
onChange={setTripCosts}
/>
{/* Trip Duration */} {/* Trip Duration */}
<TripDurationPicker <TripDurationPicker
@@ -281,18 +297,29 @@ export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
onPress={handleCancel} onPress={handleCancel}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}> <Text
style={[styles.cancelButtonText, themedStyles.cancelButtonText]}
>
{t("common.cancel")} {t("common.cancel")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.submitButton, themedStyles.submitButton]} style={[
styles.submitButton,
themedStyles.submitButton,
isSubmitting && styles.submitButtonDisabled
]}
onPress={handleSubmit} onPress={handleSubmit}
activeOpacity={0.7} activeOpacity={0.7}
disabled={isSubmitting}
> >
{isSubmitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}> <Text style={styles.submitButtonText}>
{t("diary.createTrip")} {t("diary.createTrip")}
</Text> </Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -373,6 +400,9 @@ const styles = StyleSheet.create({
borderRadius: 12, borderRadius: 12,
alignItems: "center", alignItems: "center",
}, },
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: { submitButtonText: {
fontSize: 16, fontSize: 16,
fontWeight: "600", fontWeight: "600",

View File

@@ -58,6 +58,7 @@ export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types"; export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup"; export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";
export const API_GET_LAST_TRIP = "/api/sgw/trips/last"; export const API_GET_LAST_TRIP = "/api/sgw/trips/last";
export const API_POST_TRIP = "/api/sgw/trips";
export const API_GET_ALARM = "/api/alarms"; export const API_GET_ALARM = "/api/alarms";
export const API_MANAGER_ALARM = "/api/alarms/confirm"; export const API_MANAGER_ALARM = "/api/alarms/confirm";
export const API_GET_ALL_SHIP = "/api/sgw/ships"; export const API_GET_ALL_SHIP = "/api/sgw/ships";

View File

@@ -6,6 +6,7 @@ import {
API_UPDATE_FISHING_LOGS, API_UPDATE_FISHING_LOGS,
API_UPDATE_TRIP_STATUS, API_UPDATE_TRIP_STATUS,
API_GET_LAST_TRIP, API_GET_LAST_TRIP,
API_POST_TRIP,
} from "@/constants"; } from "@/constants";
export async function queryTrip() { export async function queryTrip() {
@@ -31,3 +32,7 @@ export async function queryUpdateFishingLogs(body: Model.FishingLog) {
export async function queryTripsList(body: Model.TripListBody) { export async function queryTripsList(body: Model.TripListBody) {
return api.post(API_POST_TRIPSLIST, body); return api.post(API_POST_TRIPSLIST, body);
} }
export async function createTrip(thingId: string, body: Model.TripAPIBody) {
return api.post<Model.Trip>(`${API_POST_TRIP}/${thingId}`, body);
}

View File

@@ -200,6 +200,28 @@ declare namespace Model {
status: number; status: number;
note?: string; note?: string;
} }
// API body interface for creating a new trip
interface TripAPIBody {
thing_id?: string;
name: string;
departure_time: string; // ISO string
departure_port_id: number;
arrival_time: string; // ISO string
arrival_port_id: number;
fishing_ground_codes: number[];
fishing_gears: Array<{
name: string;
number: string;
}>;
trip_cost: Array<{
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}>;
}
//Fish //Fish
interface FishSpeciesResponse { interface FishSpeciesResponse {
id: number; id: number;

View File

@@ -165,7 +165,14 @@
"success": "Data filled from last trip", "success": "Data filled from last trip",
"error": "Unable to fetch trip data", "error": "Unable to fetch trip data",
"noData": "No previous trip data available" "noData": "No previous trip data available"
} },
"validation": {
"shipRequired": "Please select a ship before creating the trip",
"datesRequired": "Please select departure and arrival dates",
"tripNameRequired": "Please enter a trip name"
},
"createTripSuccess": "Trip created successfully!",
"createTripError": "Unable to create trip. Please try again."
}, },
"trip": { "trip": {
"infoTrip": "Trip Information", "infoTrip": "Trip Information",

View File

@@ -165,7 +165,14 @@
"success": "Đã điền dữ liệu từ chuyến đi cuối cùng", "success": "Đã điền dữ liệu từ chuyến đi cuối cùng",
"error": "Không thể lấy dữ liệu chuyến đi", "error": "Không thể lấy dữ liệu chuyến đi",
"noData": "Không có dữ liệu chuyến đi trước đó" "noData": "Không có dữ liệu chuyến đi trước đó"
} },
"validation": {
"shipRequired": "Vui lòng chọn tàu trước khi tạo chuyến đi",
"datesRequired": "Vui lòng chọn ngày khởi hành và ngày kết thúc",
"tripNameRequired": "Vui lòng nhập tên chuyến đi"
},
"createTripSuccess": "Tạo chuyến đi thành công!",
"createTripError": "Không thể tạo chuyến đi. Vui lòng thử lại."
}, },
"trip": { "trip": {
"infoTrip": "Thông Tin Chuyến Đi", "infoTrip": "Thông Tin Chuyến Đi",