cập nhật phần modal thêm chuyến đi mới

This commit is contained in:
2025-12-08 23:41:05 +07:00
parent 0e1332f433
commit 7c3497d159
16 changed files with 2775 additions and 3 deletions

View File

@@ -117,9 +117,9 @@ export default function TripCard({
{/* Info Grid */}
<View style={styles.infoGrid}>
<View style={styles.infoRow}>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipCode")}</Text>
<Text style={[styles.label, themedStyles.label]}>{t("diary.tripCard.shipName")}</Text>
<Text style={[styles.value, themedStyles.value]}>
{thingOfTrip?.metadata?.ship_reg_number /* hoặc trip.ship_id */}
{thingOfTrip?.metadata?.ship_name /* hoặc trip.ship_id */}
</Text>
</View>

View File

@@ -0,0 +1,388 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Modal,
Platform,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThings } from "@/state/use-thing";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { queryLastTrip } from "@/controller/TripController";
import { showErrorToast } from "@/services/toast_service";
interface AutoFillSectionProps {
onAutoFill: (tripData: Model.Trip, selectedShipId: string) => void;
}
export default function AutoFillSection({ onAutoFill }: AutoFillSectionProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { things } = useThings();
// Convert things to ship options
const shipOptions =
things
?.filter((thing) => thing.id != null)
.map((thing) => ({
id: thing.id as string,
shipName: thing.metadata?.ship_name || "",
})) || [];
// Filter ships based on search text
const filteredShips = shipOptions.filter((ship) => {
const searchLower = searchText.toLowerCase();
return ship.shipName.toLowerCase().includes(searchLower);
});
const handleSelectShip = async (shipId: string) => {
setIsLoading(true);
try {
const response = await queryLastTrip(shipId);
if (response.data) {
// Close the modal first before showing alert
setIsOpen(false);
setSearchText("");
// Pass shipId (thingId) along with trip data for filling ShipSelector
onAutoFill(response.data, shipId);
// Use Alert instead of Toast so it appears above all modals
Alert.alert(
t("common.success"),
t("diary.autoFill.success")
);
} else {
showErrorToast(t("diary.autoFill.noData"));
}
} catch (error) {
console.error("Error fetching last trip:", error);
showErrorToast(t("diary.autoFill.error"));
} finally {
setIsLoading(false);
}
};
const themedStyles = {
container: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.primary,
},
label: { color: colors.text },
description: { color: colors.textSecondary },
button: {
backgroundColor: colors.primary,
},
modalContent: { backgroundColor: colors.card },
searchContainer: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.border,
},
searchInput: { color: colors.text },
option: { borderBottomColor: colors.separator },
optionText: { color: colors.text },
emptyText: { color: colors.textSecondary },
};
return (
<View style={[styles.container, themedStyles.container]}>
<View style={styles.contentWrapper}>
<View style={styles.iconContainer}>
<Ionicons name="flash" size={24} color={colors.primary} />
</View>
<View style={styles.textContainer}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.autoFill.title")}
</Text>
<Text style={[styles.description, themedStyles.description]}>
{t("diary.autoFill.description")}
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.button, themedStyles.button]}
onPress={() => setIsOpen(true)}
activeOpacity={0.7}
>
<Text style={styles.buttonText}>{t("diary.autoFill.selectShip")}</Text>
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => setIsOpen(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setIsOpen(false)}
>
<View
style={[styles.modalContent, themedStyles.modalContent]}
onStartShouldSetResponder={() => true}
>
{/* Header */}
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
{t("diary.autoFill.modalTitle")}
</Text>
</View>
{/* Search Input */}
<View
style={[styles.searchContainer, themedStyles.searchContainer]}
>
<Ionicons
name="search"
size={20}
color={colors.textSecondary}
style={styles.searchIcon}
/>
<TextInput
style={[styles.searchInput, themedStyles.searchInput]}
placeholder={t("diary.searchShip")}
placeholderTextColor={colors.textSecondary}
value={searchText}
onChangeText={setSearchText}
autoCapitalize="none"
/>
{searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText("")}>
<Ionicons
name="close-circle"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
)}
</View>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("diary.autoFill.loading")}
</Text>
</View>
) : (
<ScrollView style={styles.optionsList}>
{/* Filtered ship options */}
{filteredShips.length > 0 ? (
filteredShips.map((ship) => (
<TouchableOpacity
key={ship.id}
style={[styles.option, themedStyles.option]}
onPress={() => handleSelectShip(ship.id)}
>
<View style={styles.optionContent}>
<Ionicons
name="boat"
size={20}
color={colors.primary}
style={styles.optionIcon}
/>
<Text style={[styles.optionText, themedStyles.optionText]}>
{ship.shipName}
</Text>
</View>
<Ionicons
name="chevron-forward"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
))
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noShipsFound")}
</Text>
</View>
)}
</ScrollView>
)}
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderStyle: "dashed",
},
contentWrapper: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(59, 130, 246, 0.1)",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
textContainer: {
flex: 1,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 4,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
description: {
fontSize: 13,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
button: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: "center",
},
buttonText: {
fontSize: 14,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContent: {
borderRadius: 12,
width: "85%",
maxHeight: "70%",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
modalHeader: {
paddingHorizontal: 20,
paddingVertical: 16,
},
modalTitle: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
padding: 0,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
optionsList: {
maxHeight: 350,
},
option: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
optionContent: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
optionIcon: {
marginRight: 12,
},
optionText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
emptyContainer: {
paddingVertical: 24,
alignItems: "center",
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
loadingContainer: {
paddingVertical: 40,
alignItems: "center",
},
loadingText: {
marginTop: 12,
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,89 @@
import React from "react";
import {
View,
Text,
TextInput,
StyleSheet,
Platform,
} from "react-native";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface BasicInfoInputProps {
fishingGroundCodes: string;
onChange: (value: string) => void;
}
export default function BasicInfoInput({
fishingGroundCodes,
onChange,
}: BasicInfoInputProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const themedStyles = {
label: { color: colors.text },
subLabel: { color: colors.textSecondary },
input: {
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text,
},
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.fishingGroundCodes")}
</Text>
<Text style={[styles.subLabel, themedStyles.subLabel]}>
{t("diary.fishingGroundCodesHint")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={fishingGroundCodes}
onChangeText={onChange}
placeholder={t("diary.fishingGroundCodesPlaceholder")}
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 8,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,225 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
TextInput,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface FishingGear {
id: string;
name: string;
number: string;
}
interface FishingGearListProps {
items: FishingGear[];
onChange: (items: FishingGear[]) => void;
}
export default function FishingGearList({
items,
onChange,
}: FishingGearListProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const handleAddGear = () => {
const newGear: FishingGear = {
id: Date.now().toString(),
name: "",
number: "",
};
onChange([...items, newGear]);
};
const handleRemoveGear = (id: string) => {
onChange(items.filter((item) => item.id !== id));
};
const handleDuplicateGear = (gear: FishingGear) => {
const duplicatedGear: FishingGear = {
id: Date.now().toString(),
name: gear.name,
number: gear.number,
};
onChange([...items, duplicatedGear]);
};
const handleUpdateGear = (id: string, field: keyof FishingGear, value: string) => {
onChange(
items.map((item) =>
item.id === id ? { ...item, [field]: value } : item
)
);
};
const themedStyles = {
sectionTitle: { color: colors.text },
fieldLabel: { color: colors.text },
input: {
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text,
},
addButton: {
borderColor: colors.primary,
},
addButtonText: { color: colors.primary },
};
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.fishingGearList")}
</Text>
{/* Gear Items List */}
{items.map((gear, index) => (
<View key={gear.id} style={styles.gearRow}>
{/* Name Input */}
<View style={styles.inputGroup}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.gearName")}
</Text>
<TextInput
style={[styles.input, styles.nameInput, themedStyles.input]}
value={gear.name}
onChangeText={(value) => handleUpdateGear(gear.id, "name", value)}
placeholder={t("diary.gearNamePlaceholder")}
placeholderTextColor={colors.textSecondary}
/>
</View>
{/* Number Input */}
<View style={styles.inputGroup}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.gearNumber")}
</Text>
<TextInput
style={[styles.input, styles.numberInput, themedStyles.input]}
value={gear.number}
onChangeText={(value) => handleUpdateGear(gear.id, "number", value)}
placeholder={t("diary.gearNumberPlaceholder")}
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
/>
</View>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateGear(gear)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveGear(gear.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={20} color={colors.error} />
</TouchableOpacity>
</View>
</View>
))}
{/* Add Button */}
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddGear}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addFishingGear")}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: "700",
marginBottom: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
gearRow: {
flexDirection: "row",
alignItems: "flex-end",
gap: 12,
marginBottom: 16,
},
inputGroup: {
flex: 1,
},
fieldLabel: {
fontSize: 14,
fontWeight: "500",
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
nameInput: {
flex: 1,
},
numberInput: {
flex: 1,
},
actionButtons: {
flexDirection: "row",
gap: 12,
alignItems: "center",
paddingBottom: 10,
},
addButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: 1.5,
borderRadius: 8,
borderStyle: "dashed",
marginTop: 4,
},
addButtonText: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,472 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
TextInput,
Modal,
ScrollView,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripCost {
id: string;
type: string;
amount: number;
unit: string;
cost_per_unit: number;
total_cost: number;
}
interface MaterialCostListProps {
items: TripCost[];
onChange: (items: TripCost[]) => void;
}
// Predefined cost types
const COST_TYPES = [
{ value: "fuel", label: "Nhiên liệu" },
{ value: "food", label: "Thực phẩm" },
{ value: "crew_salary", label: "Lương thuyền viên" },
{ value: "ice_salt_cost", label: "Muối đá" },
{ value: "other", label: "Khác" },
];
export default function MaterialCostList({
items,
onChange,
}: MaterialCostListProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const [typeDropdownVisible, setTypeDropdownVisible] = useState<string | null>(null);
const handleAddMaterial = () => {
const newMaterial: TripCost = {
id: Date.now().toString(),
type: "",
amount: 0,
unit: "",
cost_per_unit: 0,
total_cost: 0,
};
onChange([...items, newMaterial]);
};
const handleRemoveMaterial = (id: string) => {
onChange(items.filter((item) => item.id !== id));
};
const handleDuplicateMaterial = (material: TripCost) => {
const duplicatedMaterial: TripCost = {
id: Date.now().toString(),
type: material.type,
amount: material.amount,
unit: material.unit,
cost_per_unit: material.cost_per_unit,
total_cost: material.total_cost,
};
onChange([...items, duplicatedMaterial]);
};
const handleUpdateMaterial = (id: string, field: keyof TripCost, value: string | number) => {
onChange(
items.map((item) => {
if (item.id === id) {
const updatedItem = { ...item, [field]: value };
// Auto-calculate total_cost when amount or cost_per_unit changes
if (field === "amount" || field === "cost_per_unit") {
const amount = field === "amount" ? Number(value) : item.amount;
const costPerUnit = field === "cost_per_unit" ? Number(value) : item.cost_per_unit;
updatedItem.total_cost = amount * costPerUnit;
}
return updatedItem;
}
return item;
})
);
};
const getTypeLabel = (value: string) => {
const type = COST_TYPES.find((t) => t.value === value);
return type ? type.label : value || t("diary.selectType");
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("vi-VN").format(amount);
};
const themedStyles = {
sectionTitle: { color: colors.text },
fieldLabel: { color: colors.text },
input: {
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text,
},
dropdown: {
backgroundColor: colors.card,
borderColor: colors.border,
},
dropdownText: { color: colors.text },
placeholder: { color: colors.textSecondary },
addButton: {
borderColor: colors.primary,
},
addButtonText: { color: colors.primary },
modalOverlay: {
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
modalContent: {
backgroundColor: colors.card,
},
option: {
borderBottomColor: colors.separator,
},
optionText: { color: colors.text },
};
return (
<View style={styles.container}>
<Text style={[styles.sectionTitle, themedStyles.sectionTitle]}>
{t("diary.materialCostList")}
</Text>
{/* Cost Items List */}
{items.map((cost) => (
<View key={cost.id} style={styles.costRow}>
<View style={styles.formRow}>
{/* Type Dropdown */}
<View style={[styles.inputGroup, styles.typeGroup]}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.costType")}
</Text>
<TouchableOpacity
style={[styles.dropdown, themedStyles.dropdown]}
onPress={() => setTypeDropdownVisible(cost.id)}
activeOpacity={0.7}
>
<Text
style={[
styles.dropdownText,
themedStyles.dropdownText,
!cost.type && themedStyles.placeholder,
]}
>
{getTypeLabel(cost.type)}
</Text>
<Ionicons
name="chevron-down"
size={16}
color={colors.textSecondary}
/>
</TouchableOpacity>
{/* Type Dropdown Modal */}
<Modal
visible={typeDropdownVisible === cost.id}
transparent
animationType="fade"
onRequestClose={() => setTypeDropdownVisible(null)}
>
<TouchableOpacity
style={[styles.modalOverlay, themedStyles.modalOverlay]}
activeOpacity={1}
onPress={() => setTypeDropdownVisible(null)}
>
<View style={[styles.modalContent, themedStyles.modalContent]}>
<ScrollView>
{COST_TYPES.map((type) => (
<TouchableOpacity
key={type.value}
style={[styles.option, themedStyles.option]}
onPress={() => {
handleUpdateMaterial(cost.id, "type", type.value);
setTypeDropdownVisible(null);
}}
>
<Text
style={[styles.optionText, themedStyles.optionText]}
>
{type.label}
</Text>
{cost.type === type.value && (
<Ionicons
name="checkmark"
size={20}
color={colors.primary}
/>
)}
</TouchableOpacity>
))}
</ScrollView>
</View>
</TouchableOpacity>
</Modal>
</View>
{/* Amount Input */}
<View style={[styles.inputGroup, styles.smallGroup]}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.amount")}
</Text>
<TextInput
style={[styles.input, styles.smallInput, themedStyles.input]}
value={cost.amount.toString()}
onChangeText={(value) =>
handleUpdateMaterial(cost.id, "amount", Number(value) || 0)
}
placeholder="0"
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
/>
</View>
{/* Unit Input */}
<View style={[styles.inputGroup, styles.smallGroup]}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.unit")}
</Text>
<TextInput
style={[styles.input, styles.smallInput, themedStyles.input]}
value={cost.unit}
onChangeText={(value) =>
handleUpdateMaterial(cost.id, "unit", value)
}
placeholder={t("diary.unitPlaceholder")}
placeholderTextColor={colors.textSecondary}
/>
</View>
</View>
<View style={styles.formRow}>
{/* Cost Per Unit Input */}
<View style={[styles.inputGroup, styles.mediumGroup]}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.costPerUnit")}
</Text>
<TextInput
style={[styles.input, styles.mediumInput, themedStyles.input]}
value={cost.cost_per_unit.toString()}
onChangeText={(value) =>
handleUpdateMaterial(
cost.id,
"cost_per_unit",
Number(value) || 0
)
}
placeholder="0"
placeholderTextColor={colors.textSecondary}
keyboardType="numeric"
/>
</View>
{/* Total Cost (Read-only, auto-calculated) */}
<View style={[styles.inputGroup, styles.mediumGroup]}>
<Text style={[styles.fieldLabel, themedStyles.fieldLabel]}>
{t("diary.totalCost")}
</Text>
<View style={[styles.input, styles.mediumInput, themedStyles.input, styles.readOnlyInput]}>
<Text style={[styles.readOnlyText, themedStyles.dropdownText]}>
{formatCurrency(cost.total_cost)}
</Text>
</View>
</View>
{/* Action Buttons */}
<View style={styles.actionButtons}>
<TouchableOpacity
onPress={() => handleDuplicateMaterial(cost)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="copy-outline" size={20} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleRemoveMaterial(cost.id)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.error}
/>
</TouchableOpacity>
</View>
</View>
</View>
))}
{/* Add Button */}
<TouchableOpacity
style={[styles.addButton, themedStyles.addButton]}
onPress={handleAddMaterial}
activeOpacity={0.7}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={[styles.addButtonText, themedStyles.addButtonText]}>
{t("diary.addMaterialCost")}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: "700",
marginBottom: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
costRow: {
marginBottom: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#E5E5E5",
},
formRow: {
flexDirection: "row",
alignItems: "flex-end",
gap: 12,
marginBottom: 12,
},
inputGroup: {
flex: 1,
},
typeGroup: {
flex: 1.5,
},
smallGroup: {
flex: 0.8,
},
mediumGroup: {
flex: 1,
},
fieldLabel: {
fontSize: 14,
fontWeight: "500",
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
smallInput: {},
mediumInput: {},
dropdown: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
dropdownText: {
fontSize: 15,
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
readOnlyInput: {
justifyContent: "center",
opacity: 0.7,
},
readOnlyText: {
fontSize: 15,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
actionButtons: {
flexDirection: "row",
gap: 12,
alignItems: "center",
paddingBottom: 10,
},
addButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: 1.5,
borderRadius: 8,
borderStyle: "dashed",
marginTop: 4,
},
addButtonText: {
fontSize: 14,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
// Modal styles
modalOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
modalContent: {
borderRadius: 12,
width: "80%",
maxHeight: "50%",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
option: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
optionText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,171 @@
import React from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface PortSelectorProps {
departurePortId: number;
arrivalPortId: number;
onDeparturePortChange: (portId: number) => void;
onArrivalPortChange: (portId: number) => void;
}
export default function PortSelector({
departurePortId,
arrivalPortId,
onDeparturePortChange,
onArrivalPortChange,
}: PortSelectorProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const handleSelectDeparturePort = () => {
console.log("Select departure port pressed");
// TODO: Implement port selection modal/dropdown
// For now, just set a dummy ID
onDeparturePortChange(1);
};
const handleSelectArrivalPort = () => {
console.log("Select arrival port pressed");
// TODO: Implement port selection modal/dropdown
// For now, just set a dummy ID
onArrivalPortChange(1);
};
// Helper to display port name (in production, fetch from port list by ID)
const getPortDisplayName = (portId: number): string => {
// TODO: Fetch actual port name by ID from port list
return portId ? `Cảng (ID: ${portId})` : t("diary.selectPort");
};
const themedStyles = {
label: { color: colors.text },
portSelector: {
backgroundColor: colors.card,
borderColor: colors.border,
},
portText: { color: colors.text },
placeholder: { color: colors.textSecondary },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.portLabel")}
</Text>
<View style={styles.portContainer}>
{/* Departure Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.departurePort")}
</Text>
<TouchableOpacity
style={[styles.portSelector, themedStyles.portSelector]}
onPress={handleSelectDeparturePort}
activeOpacity={0.7}
>
<Text
style={[
styles.portText,
themedStyles.portText,
!departurePortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(departurePortId)}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* Arrival Port */}
<View style={styles.portSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.arrivalPort")}
</Text>
<TouchableOpacity
style={[styles.portSelector, themedStyles.portSelector]}
onPress={handleSelectArrivalPort}
activeOpacity={0.7}
>
<Text
style={[
styles.portText,
themedStyles.portText,
!arrivalPortId && themedStyles.placeholder,
]}
>
{getPortDisplayName(arrivalPortId)}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
portContainer: {
flexDirection: "row",
gap: 12,
},
portSection: {
flex: 1,
},
portSelector: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
portText: {
fontSize: 15,
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,293 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Modal,
Platform,
ScrollView,
TextInput,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThings } from "@/state/use-thing";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface ShipSelectorProps {
selectedShipId: string;
onChange: (shipId: string) => void;
}
export default function ShipSelector({
selectedShipId,
onChange,
}: ShipSelectorProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const [isOpen, setIsOpen] = useState(false);
const [searchText, setSearchText] = useState("");
const { things } = useThings();
// Convert things to ship options
const shipOptions =
things
?.filter((thing) => thing.id != null)
.map((thing) => ({
id: thing.id as string,
shipName: thing.metadata?.ship_name || "",
})) || [];
// Filter ships based on search text
const filteredShips = shipOptions.filter((ship) => {
const searchLower = searchText.toLowerCase();
return ship.shipName.toLowerCase().includes(searchLower);
});
const handleSelect = (shipId: string) => {
onChange(shipId);
setIsOpen(false);
setSearchText("");
};
const selectedShip = shipOptions.find((ship) => ship.id === selectedShipId);
const displayValue = selectedShip
? selectedShip.shipName
: t("diary.selectShip");
const themedStyles = {
label: { color: colors.text },
selector: { backgroundColor: colors.card, borderColor: colors.border },
selectorText: { color: colors.text },
placeholder: { color: colors.textSecondary },
modalContent: { backgroundColor: colors.card },
searchContainer: {
backgroundColor: colors.backgroundSecondary,
borderColor: colors.border,
},
searchInput: { color: colors.text },
option: { borderBottomColor: colors.separator },
selectedOption: { backgroundColor: colors.backgroundSecondary },
optionText: { color: colors.text },
emptyText: { color: colors.textSecondary },
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.shipSelector")}
<Text style={styles.required}> *</Text>
</Text>
<TouchableOpacity
style={[styles.selector, themedStyles.selector]}
onPress={() => setIsOpen(true)}
activeOpacity={0.7}
>
<Text
style={[
styles.selectorText,
themedStyles.selectorText,
!selectedShipId && themedStyles.placeholder,
]}
>
{displayValue}
</Text>
<Ionicons
name="ellipsis-horizontal"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => setIsOpen(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setIsOpen(false)}
>
<View
style={[styles.modalContent, themedStyles.modalContent]}
onStartShouldSetResponder={() => true}
>
{/* Search Input */}
<View
style={[styles.searchContainer, themedStyles.searchContainer]}
>
<Ionicons
name="search"
size={20}
color={colors.textSecondary}
style={styles.searchIcon}
/>
<TextInput
style={[styles.searchInput, themedStyles.searchInput]}
placeholder={t("diary.searchShip")}
placeholderTextColor={colors.textSecondary}
value={searchText}
onChangeText={setSearchText}
autoCapitalize="none"
/>
{searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText("")}>
<Ionicons
name="close-circle"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
)}
</View>
<ScrollView style={styles.optionsList}>
{/* Filtered ship options */}
{filteredShips.length > 0 ? (
filteredShips.map((ship) => (
<TouchableOpacity
key={ship.id}
style={[
styles.option,
themedStyles.option,
selectedShipId === ship.id && themedStyles.selectedOption,
]}
onPress={() => handleSelect(ship.id)}
>
<Text style={[styles.optionText, themedStyles.optionText]}>
{ship.shipName}
</Text>
{selectedShipId === ship.id && (
<Ionicons
name="checkmark"
size={20}
color={colors.primary}
/>
)}
</TouchableOpacity>
))
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, themedStyles.emptyText]}>
{t("diary.noShipsFound")}
</Text>
</View>
)}
</ScrollView>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 8,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
required: {
color: "#EF4444",
},
selector: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
},
selectorText: {
fontSize: 16,
flex: 1,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContent: {
borderRadius: 12,
width: "85%",
maxHeight: "70%",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
searchIcon: {
marginRight: 8,
},
searchInput: {
flex: 1,
fontSize: 16,
padding: 0,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
optionsList: {
maxHeight: 350,
},
option: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
optionText: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
emptyContainer: {
paddingVertical: 24,
alignItems: "center",
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,289 @@
import React, { useState } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Modal,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripDurationPickerProps {
startDate: Date | null;
endDate: Date | null;
onStartDateChange: (date: Date | null) => void;
onEndDateChange: (date: Date | null) => void;
}
export default function TripDurationPicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
}: TripDurationPickerProps) {
const { t } = useI18n();
const { colors, colorScheme } = useThemeContext();
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const formatDate = (date: Date | null) => {
if (!date) return "";
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
const handleStartDateChange = (event: any, selectedDate?: Date) => {
setShowStartPicker(Platform.OS === "ios");
if (selectedDate) {
onStartDateChange(selectedDate);
}
};
const handleEndDateChange = (event: any, selectedDate?: Date) => {
setShowEndPicker(Platform.OS === "ios");
if (selectedDate) {
onEndDateChange(selectedDate);
}
};
const themedStyles = {
label: { color: colors.text },
dateInput: {
backgroundColor: colors.card,
borderColor: colors.border,
},
dateText: { color: colors.text },
placeholder: { color: colors.textSecondary },
pickerContainer: { backgroundColor: colors.card },
pickerHeader: { borderBottomColor: colors.border },
pickerTitle: { color: colors.text },
cancelButton: { color: colors.textSecondary },
};
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")}
</Text>
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowStartPicker(true)}
activeOpacity={0.7}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!startDate && themedStyles.placeholder,
]}
>
{startDate ? formatDate(startDate) : t("diary.selectDate")}
</Text>
<Ionicons
name="calendar-outline"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* End Date */}
<View style={styles.dateSection}>
<Text style={[styles.subLabel, themedStyles.placeholder]}>
{t("diary.endDate")}
</Text>
<TouchableOpacity
style={[styles.dateInput, themedStyles.dateInput]}
onPress={() => setShowEndPicker(true)}
activeOpacity={0.7}
>
<Text
style={[
styles.dateText,
themedStyles.dateText,
!endDate && themedStyles.placeholder,
]}
>
{endDate ? formatDate(endDate) : t("diary.selectDate")}
</Text>
<Ionicons
name="calendar-outline"
size={20}
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={() => setShowStartPicker(false)}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={startDate || new Date()}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleStartDateChange}
maximumDate={endDate || undefined}
themeVariant={colorScheme}
textColor={colors.text}
/>
</View>
</View>
</Modal>
)}
{/* End Date Picker */}
{showEndPicker && (
<Modal transparent animationType="fade" visible={showEndPicker}>
<View style={styles.modalOverlay}>
<View style={[styles.pickerContainer, themedStyles.pickerContainer]}>
<View style={[styles.pickerHeader, themedStyles.pickerHeader]}>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={[styles.cancelButton, themedStyles.cancelButton]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<Text style={[styles.pickerTitle, themedStyles.pickerTitle]}>
{t("diary.selectEndDate")}
</Text>
<TouchableOpacity onPress={() => setShowEndPicker(false)}>
<Text style={styles.doneButton}>{t("common.done")}</Text>
</TouchableOpacity>
</View>
<DateTimePicker
value={endDate || new Date()}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
onChange={handleEndDateChange}
minimumDate={startDate || undefined}
themeVariant={colorScheme}
textColor={colors.text}
/>
</View>
</View>
</Modal>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 12,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
subLabel: {
fontSize: 14,
marginBottom: 6,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
dateRangeContainer: {
flexDirection: "row",
gap: 12,
},
dateSection: {
flex: 1,
},
dateInput: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
},
dateText: {
fontSize: 15,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
pickerContainer: {
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 20,
},
pickerHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
pickerTitle: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
cancelButton: {
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
doneButton: {
fontSize: 16,
fontWeight: "600",
color: "#3B82F6",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,72 @@
import React from "react";
import {
View,
Text,
TextInput,
StyleSheet,
Platform,
} from "react-native";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
interface TripNameInputProps {
value: string;
onChange: (value: string) => void;
}
export default function TripNameInput({ value, onChange }: TripNameInputProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
const themedStyles = {
label: { color: colors.text },
input: {
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text,
},
};
return (
<View style={styles.container}>
<Text style={[styles.label, themedStyles.label]}>
{t("diary.tripNameLabel")}
</Text>
<TextInput
style={[styles.input, themedStyles.input]}
value={value}
onChangeText={onChange}
placeholder={t("diary.tripNamePlaceholder")}
placeholderTextColor={colors.textSecondary}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: "600",
marginBottom: 8,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -0,0 +1,386 @@
import React, { useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
Platform,
ScrollView,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import FishingGearList from "@/components/diary/addTripModal/FishingGearList";
import MaterialCostList from "@/components/diary/addTripModal/MaterialCostList";
import TripNameInput from "@/components/diary/addTripModal/TripNameInput";
import TripDurationPicker from "@/components/diary/addTripModal/TripDurationPicker";
import PortSelector from "@/components/diary/addTripModal/PortSelector";
import BasicInfoInput from "@/components/diary/addTripModal/BasicInfoInput";
import ShipSelector from "./ShipSelector";
import AutoFillSection from "./AutoFillSection";
// Internal component interfaces
export interface FishingGear {
id: string;
name: string;
number: string; // Changed from quantity to number (string)
}
export interface TripCost {
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 {
visible: boolean;
onClose: () => void;
}
export default function AddTripModal({ visible, onClose }: AddTripModalProps) {
const { t } = useI18n();
const { colors } = useThemeContext();
// Form state
const [selectedShipId, setSelectedShipId] = useState<string>("");
const [tripName, setTripName] = useState("");
const [fishingGears, setFishingGears] = useState<FishingGear[]>([]);
const [tripCosts, setTripCosts] = useState<TripCost[]>([]);
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);
const [departurePortId, setDeparturePortId] = useState<number>(1);
const [arrivalPortId, setArrivalPortId] = useState<number>(1);
const [fishingGroundCodes, setFishingGroundCodes] = useState<string>(""); // Input as string, convert to array
const handleCancel = () => {
// Reset form
setSelectedShipId("");
setTripName("");
setFishingGears([]);
setTripCosts([]);
setStartDate(null);
setEndDate(null);
setDeparturePortId(1);
setArrivalPortId(1);
setFishingGroundCodes("");
onClose();
};
// Handle auto-fill from last trip data
const handleAutoFill = (tripData: Model.Trip, selectedThingId: string) => {
// Fill ship ID (use the thingId from the selected ship for ShipSelector)
setSelectedShipId(selectedThingId);
// Fill trip name
if (tripData.name) {
setTripName(tripData.name);
}
// Fill fishing gears
if (tripData.fishing_gears && Array.isArray(tripData.fishing_gears)) {
const gears: FishingGear[] = tripData.fishing_gears.map((gear, index) => ({
id: `auto-${Date.now()}-${index}`,
name: gear.name || "",
number: gear.number?.toString() || "",
}));
setFishingGears(gears);
}
// Fill trip costs
if (tripData.trip_cost && Array.isArray(tripData.trip_cost)) {
const costs: TripCost[] = tripData.trip_cost.map((cost, index) => ({
id: `auto-${Date.now()}-${index}`,
type: cost.type || "",
amount: cost.amount || 0,
unit: cost.unit || "",
cost_per_unit: cost.cost_per_unit || 0,
total_cost: cost.total_cost || 0,
}));
setTripCosts(costs);
}
// Fill departure and arrival ports
if (tripData.departure_port_id) {
setDeparturePortId(tripData.departure_port_id);
}
if (tripData.arrival_port_id) {
setArrivalPortId(tripData.arrival_port_id);
}
// Fill fishing ground codes
if (tripData.fishing_ground_codes && Array.isArray(tripData.fishing_ground_codes)) {
setFishingGroundCodes(tripData.fishing_ground_codes.join(", "));
}
};
const handleSubmit = () => {
// Parse fishing ground codes from comma-separated string to array of numbers
const fishingGroundCodesArray = fishingGroundCodes
.split(",")
.map((code) => parseInt(code.trim()))
.filter((code) => !isNaN(code));
// Format API body
const apiBody: TripAPIBody = {
thing_id: selectedShipId || undefined,
name: tripName,
departure_time: startDate ? startDate.toISOString() : "",
departure_port_id: departurePortId,
arrival_time: endDate ? endDate.toISOString() : "",
arrival_port_id: arrivalPortId,
fishing_ground_codes: fishingGroundCodesArray,
fishing_gears: fishingGears.map((gear) => ({
name: gear.name,
number: gear.number,
})),
trip_cost: tripCosts.map((cost) => ({
type: cost.type,
amount: cost.amount,
unit: cost.unit,
cost_per_unit: cost.cost_per_unit,
total_cost: cost.total_cost,
})),
};
// Simulate API call - log the formatted data
console.log("=== Submitting Trip Data (API Format) ===");
console.log(JSON.stringify(apiBody, null, 2));
console.log("=== End Trip Data ===");
// Reset form and close modal
handleCancel();
};
const themedStyles = {
modalContainer: {
backgroundColor: colors.card,
},
header: {
borderBottomColor: colors.separator,
},
title: {
color: colors.text,
},
footer: {
borderTopColor: colors.separator,
},
cancelButton: {
backgroundColor: colors.backgroundSecondary,
},
cancelButtonText: {
color: colors.textSecondary,
},
submitButton: {
backgroundColor: colors.primary,
},
};
return (
<Modal
visible={visible}
animationType="fade"
transparent
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={[styles.modalContainer, themedStyles.modalContainer]}>
{/* Header */}
<View style={[styles.header, themedStyles.header]}>
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, themedStyles.title]}>
{t("diary.addTrip")}
</Text>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Auto Fill Section */}
<AutoFillSection onAutoFill={handleAutoFill} />
{/* Ship Selector */}
<ShipSelector
selectedShipId={selectedShipId}
onChange={setSelectedShipId}
/>
{/* Trip Name */}
<TripNameInput value={tripName} onChange={setTripName} />
{/* Fishing Gear List */}
<FishingGearList
items={fishingGears}
onChange={setFishingGears}
/>
{/* Trip Cost List */}
<MaterialCostList
items={tripCosts}
onChange={setTripCosts}
/>
{/* Trip Duration */}
<TripDurationPicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Port Selector */}
<PortSelector
departurePortId={departurePortId}
arrivalPortId={arrivalPortId}
onDeparturePortChange={setDeparturePortId}
onArrivalPortChange={setArrivalPortId}
/>
{/* Fishing Ground Codes */}
<BasicInfoInput
fishingGroundCodes={fishingGroundCodes}
onChange={setFishingGroundCodes}
/>
</ScrollView>
{/* Footer */}
<View style={[styles.footer, themedStyles.footer]}>
<TouchableOpacity
style={[styles.cancelButton, themedStyles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={[styles.cancelButtonText, themedStyles.cancelButtonText]}>
{t("common.cancel")}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, themedStyles.submitButton]}
onPress={handleSubmit}
activeOpacity={0.7}
>
<Text style={styles.submitButtonText}>
{t("diary.createTrip")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "90%",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: -4,
},
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
placeholder: {
width: 32,
},
content: {
padding: 20,
},
footer: {
flexDirection: "row",
gap: 12,
padding: 20,
borderTopWidth: 1,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
submitButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: "center",
},
submitButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#FFFFFF",
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});