From 190e44b09e912446bac010453fc71f19bd8aaa4b Mon Sep 17 00:00:00 2001 From: MinhNN Date: Wed, 24 Dec 2025 21:58:18 +0700 Subject: [PATCH] =?UTF-8?q?c=E1=BA=ADp=20nh=E1=BA=ADt=20modal=20CRUD=20thu?= =?UTF-8?q?y=E1=BB=81n=20vi=C3=AAn=20trong=20trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/trip-crew.tsx | 104 +++- .../diary/TripCrewModal/AddEditCrewModal.tsx | 534 +++++++++++++++++- components/diary/TripCrewModal/CrewCard.tsx | 76 ++- config/axios.ts | 7 +- constants/index.ts | 3 + controller/CrewController.ts | 19 + controller/TripController.ts | 1 + controller/TripCrewController.ts | 25 +- controller/typings.d.ts | 31 + locales/en.json | 59 +- locales/vi.json | 8 +- package-lock.json | 22 + package.json | 1 + 13 files changed, 822 insertions(+), 68 deletions(-) create mode 100644 controller/CrewController.ts diff --git a/app/trip-crew.tsx b/app/trip-crew.tsx index a43bfbb..7ffad55 100644 --- a/app/trip-crew.tsx +++ b/app/trip-crew.tsx @@ -13,7 +13,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useI18n } from "@/hooks/use-i18n"; import { useThemeContext } from "@/hooks/use-theme-context"; -import { queryTripCrew } from "@/controller/TripCrewController"; +import { queryTripCrew, deleteTripCrew } from "@/controller/TripCrewController"; import CrewList from "@/components/diary/TripCrewModal/CrewList"; import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal"; @@ -21,7 +21,10 @@ export default function TripCrewPage() { const { t } = useI18n(); const { colors } = useThemeContext(); const router = useRouter(); - const { tripId, tripName } = useLocalSearchParams<{ tripId: string; tripName?: string }>(); + const { tripId, tripName } = useLocalSearchParams<{ + tripId: string; + tripName?: string; + }>(); // State const [loading, setLoading] = useState(false); @@ -85,9 +88,16 @@ export default function TripCrewPage() { text: t("common.delete"), style: "destructive", onPress: async () => { - // TODO: Call delete API when available - setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID)); - Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); + try { + // Call delete API + await deleteTripCrew(tripId, crew.PersonalID || ""); + // Reload list + await fetchCrewData(); + Alert.alert(t("common.success"), t("diary.crew.deleteSuccess")); + } catch (error) { + console.error("Error deleting crew:", error); + Alert.alert(t("common.error"), t("diary.crew.form.saveError")); + } }, }, ] @@ -102,16 +112,25 @@ export default function TripCrewPage() { const themedStyles = { container: { backgroundColor: colors.background }, - header: { backgroundColor: colors.card, borderBottomColor: colors.separator }, + header: { + backgroundColor: colors.card, + borderBottomColor: colors.separator, + }, title: { color: colors.text }, subtitle: { color: colors.textSecondary }, }; return ( - + {/* Header */} - router.back()} style={styles.backButton}> + router.back()} + style={styles.backButton} + > @@ -119,7 +138,10 @@ export default function TripCrewPage() { {t("diary.crew.title")} {tripName && ( - + {tripName} )} @@ -140,8 +162,14 @@ export default function TripCrewPage() { ) : error ? ( - - + + {error} ) : ( - + )} {/* Footer - Crew count */} - + @@ -175,6 +212,8 @@ export default function TripCrewPage() { }} onSave={handleSaveCrew} mode={editingCrew ? "edit" : "add"} + tripId={tripId} + existingCrewIds={crews.map((c) => c.PersonalID || "")} initialData={ editingCrew ? { @@ -184,7 +223,8 @@ export default function TripCrewPage() { email: editingCrew.Person?.email || "", address: editingCrew.Person?.address || "", role: editingCrew.role || "crew", - note: editingCrew.note || "", + // Note lấy từ trip (ghi chú chuyến đi), fallback về note từ Person + note: editingCrew.note || editingCrew.Person?.note || "", } : undefined } @@ -218,12 +258,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, @@ -237,7 +285,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, @@ -249,7 +301,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, @@ -261,7 +317,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, @@ -278,6 +338,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", + }), }, }); diff --git a/components/diary/TripCrewModal/AddEditCrewModal.tsx b/components/diary/TripCrewModal/AddEditCrewModal.tsx index 3f1e08c..7d032d6 100644 --- a/components/diary/TripCrewModal/AddEditCrewModal.tsx +++ b/components/diary/TripCrewModal/AddEditCrewModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, @@ -12,10 +12,23 @@ import { Animated, Dimensions, Alert, + Image, } from "react-native"; 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 { + newCrew, + updateCrewInfo, + queryCrewImage, +} from "@/controller/CrewController"; +import * as ImagePicker from "expo-image-picker"; +import { Buffer } from "buffer"; interface CrewFormData { personalId: string; @@ -33,9 +46,12 @@ interface AddEditCrewModalProps { onSave: (data: CrewFormData) => Promise; mode: "add" | "edit"; initialData?: Partial; + tripId?: string; // Required for add mode to add crew to trip + existingCrewIds?: string[]; // List of existing crew IDs in trip } -const ROLES = ["captain", "crew", "engineer", "cook"]; +const ROLES = ["captain", "crew"]; +const DEBOUNCE_DELAY = 1000; // 3 seconds debounce export default function AddEditCrewModal({ visible, @@ -43,13 +59,17 @@ export default function AddEditCrewModal({ onSave, mode, initialData, + tripId, + existingCrewIds = [], }: AddEditCrewModalProps) { const { t } = useI18n(); const { colors } = useThemeContext(); // Animation values const fadeAnim = useState(new Animated.Value(0))[0]; - const slideAnim = useState(new Animated.Value(Dimensions.get("window").height))[0]; + const slideAnim = useState( + new Animated.Value(Dimensions.get("window").height) + )[0]; // Form state const [formData, setFormData] = useState({ @@ -62,6 +82,21 @@ export default function AddEditCrewModal({ note: "", }); const [isSubmitting, setIsSubmitting] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [isWaitingDebounce, setIsWaitingDebounce] = useState(false); + const [searchStatus, setSearchStatus] = useState< + "idle" | "found" | "not_found" | "error" + >("idle"); + const [foundPersonData, setFoundPersonData] = + useState(null); + + // State quản lý ảnh thuyền viên + const [imageUri, setImageUri] = useState(null); + const [newImageUri, setNewImageUri] = useState(null); + const [isLoadingImage, setIsLoadingImage] = useState(false); + + // Debounce timer ref + const debounceTimerRef = useRef | null>(null); // Pre-fill form when editing useEffect(() => { @@ -75,6 +110,11 @@ export default function AddEditCrewModal({ role: initialData.role || "crew", note: initialData.note || "", }); + setSearchStatus("idle"); + setFoundPersonData(null); + // Reset ảnh khi mở modal edit + setNewImageUri(null); + setImageUri(null); } else if (visible && mode === "add") { // Reset form for add mode setFormData({ @@ -86,9 +126,153 @@ export default function AddEditCrewModal({ role: "crew", note: "", }); + setSearchStatus("idle"); + setFoundPersonData(null); + // Reset ảnh + setNewImageUri(null); + setImageUri(null); } }, [visible, initialData, mode]); + // Load ảnh thuyền viên khi edit + useEffect(() => { + const loadCrewImage = async () => { + if (visible && mode === "edit" && initialData?.personalId) { + setIsLoadingImage(true); + try { + const response = await queryCrewImage(initialData.personalId); + if (response.data) { + const base64 = Buffer.from(response.data as ArrayBuffer).toString( + "base64" + ); + setImageUri(`data:image/jpeg;base64,${base64}`); + } + } catch (error) { + // Không có ảnh hoặc lỗi - hiển thị placeholder + setImageUri(null); + } finally { + setIsLoadingImage(false); + } + } + }; + loadCrewImage(); + }, [visible, mode, initialData?.personalId]); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Search crew by personal ID with debounce + const searchCrewByPersonalId = useCallback(async (personalId: string) => { + if (!personalId.trim() || personalId.length < 5) { + setSearchStatus("idle"); + return; + } + + setIsSearching(true); + setSearchStatus("idle"); + + try { + const response = await searchCrew(personalId.trim()); + if (response.data) { + const person = response.data; + setFoundPersonData(person); + setSearchStatus("found"); + + // Auto-fill form with found data + setFormData((prev) => ({ + ...prev, + name: person.name || prev.name, + phone: person.phone || prev.phone, + email: person.email || prev.email, + address: person.address || prev.address, + note: person.note || prev.note, + })); + } else { + setSearchStatus("not_found"); + setFoundPersonData(null); + } + } catch (error: any) { + // 404 or 401 means not found or unauthorized - treat as not found silently + const status = error?.response?.status; + if (status === 404 || status === 401) { + setSearchStatus("not_found"); + } else { + // For other errors, just set to not_found without logging to console + setSearchStatus("not_found"); + } + setFoundPersonData(null); + } finally { + setIsSearching(false); + } + }, []); + + // Handle personal ID change with debounce + const handlePersonalIdChange = (value: string) => { + // If crew was previously found and user is changing personal ID, reset form + if (foundPersonData && value !== formData.personalId) { + setFormData({ + personalId: value, + name: "", + phone: "", + email: "", + address: "", + role: "crew", + note: "", + }); + setFoundPersonData(null); + } else { + setFormData((prev) => ({ ...prev, personalId: value })); + } + + setSearchStatus("idle"); + setIsWaitingDebounce(false); + + // Clear previous timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Only search in add mode and when value is long enough + if (mode === "add" && value.trim().length >= 5) { + // Show waiting indicator + setIsWaitingDebounce(true); + + // Set new timer + debounceTimerRef.current = setTimeout(() => { + setIsWaitingDebounce(false); + searchCrewByPersonalId(value); + }, DEBOUNCE_DELAY); + } + }; + + // Chọn ảnh từ thư viện + const pickImage = async () => { + // Xin quyền truy cập thư viện ảnh + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== "granted") { + Alert.alert(t("common.error"), "Cần cấp quyền truy cập thư viện ảnh"); + return; + } + + // Mở image picker + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setNewImageUri(result.assets[0].uri); + } + }; + // Handle animation useEffect(() => { if (visible) { @@ -108,6 +292,11 @@ export default function AddEditCrewModal({ }, [visible, fadeAnim, slideAnim]); const handleClose = () => { + // Clear debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + Animated.parallel([ Animated.timing(fadeAnim, { toValue: 0, @@ -125,7 +314,7 @@ export default function AddEditCrewModal({ }; const handleSave = async () => { - // Validate required fields + // === VALIDATE DỮ LIỆU === if (!formData.personalId.trim()) { Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired")); return; @@ -135,11 +324,65 @@ export default function AddEditCrewModal({ return; } + // Kiểm tra thuyền viên đã có trong chuyến đi chưa (chỉ với mode add) + if ( + mode === "add" && + existingCrewIds.includes(formData.personalId.trim()) + ) { + Alert.alert(t("common.error"), t("diary.crew.form.crewAlreadyExists")); + return; + } + setIsSubmitting(true); try { + if (mode === "add" && tripId) { + // === CHẾ ĐỘ THÊM MỚI === + + // 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) { + await newCrew({ + personal_id: formData.personalId.trim(), + name: formData.name.trim(), + phone: formData.phone || "", + email: formData.email || "", + birth_date: new Date(), + note: formData.note || "", + address: formData.address || "", + }); + } + + // Bước 2: Thêm thuyền viên vào chuyến đi với role + await newTripCrew({ + trip_id: tripId, + personal_id: formData.personalId.trim(), + role: formData.role as "captain" | "crew", + }); + } else if (mode === "edit" && tripId) { + // === 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 || "", + }); + + // 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 || "", + }); + } + + // Gọi callback để reload danh sách await onSave(formData); handleClose(); - } catch (error) { + } catch (error: any) { + console.error("Lỗi khi lưu thuyền viên:", error); Alert.alert(t("common.error"), t("diary.crew.form.saveError")); } finally { setIsSubmitting(false); @@ -150,6 +393,65 @@ export default function AddEditCrewModal({ setFormData((prev) => ({ ...prev, [field]: value })); }; + // Render search status message + const renderSearchStatus = () => { + if (isSearching) { + return ( + + + + {t("diary.crew.form.searching")} + + + ); + } + + if (searchStatus === "found") { + return ( + + + + {t("diary.crew.form.crewFound")} + + + ); + } + + if (searchStatus === "not_found") { + return ( + + + + {t("diary.crew.form.crewNotFound")} + + + ); + } + + return null; + }; + const themedStyles = { modalContainer: { backgroundColor: colors.card }, header: { borderBottomColor: colors.separator }, @@ -194,26 +496,96 @@ export default function AddEditCrewModal({ - {mode === "add" ? t("diary.crew.form.addTitle") : t("diary.crew.form.editTitle")} + {mode === "add" + ? t("diary.crew.form.addTitle") + : t("diary.crew.form.editTitle")} {/* Content */} - + + {/* Ảnh thuyền viên */} + + + {isLoadingImage ? ( + + ) : newImageUri || imageUri ? ( + + ) : ( + + + + )} + + + Nhấn để chọn ảnh + + + {/* Personal ID */} {t("diary.crew.personalId")} * - updateField("personalId", v)} - placeholder={t("diary.crew.form.personalIdPlaceholder")} - placeholderTextColor={themedStyles.placeholder.color} - editable={mode === "add"} - /> + + + {/* Show waiting icon during debounce, spinner during search */} + {(isWaitingDebounce || isSearching) && ( + + {isSearching ? ( + + ) : ( + + )} + + )} + + {mode === "add" && ( + + {isWaitingDebounce + ? t("diary.crew.form.waitingSearch") + : t("diary.crew.form.searchHint")} + + )} + {renderSearchStatus()} {/* Name */} @@ -309,10 +681,18 @@ export default function AddEditCrewModal({ {/* Footer */} - + {t("common.cancel")} @@ -363,7 +743,11 @@ 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", + }), }, headerPlaceholder: { width: 32, @@ -378,7 +762,11 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: "600", marginBottom: 8, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, input: { height: 44, @@ -386,7 +774,52 @@ const styles = StyleSheet.create({ borderWidth: 1, paddingHorizontal: 14, fontSize: 15, - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + inputWithIcon: { + position: "relative", + }, + inputWithIconInput: { + paddingRight: 44, + }, + inputIcon: { + position: "absolute", + right: 12, + top: 0, + bottom: 0, + justifyContent: "center", + }, + hint: { + fontSize: 12, + marginTop: 4, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), + }, + statusContainer: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginTop: 8, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + }, + statusSuccess: {}, + statusWarning: {}, + statusText: { + fontSize: 13, + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, textArea: { height: 80, @@ -407,7 +840,11 @@ const styles = StyleSheet.create({ roleButtonText: { fontSize: 13, fontWeight: "500", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, footer: { flexDirection: "row", @@ -424,7 +861,11 @@ const styles = StyleSheet.create({ cancelButtonText: { fontSize: 16, fontWeight: "600", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, saveButton: { flex: 1, @@ -436,9 +877,56 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: "600", color: "#FFFFFF", - fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }), + fontFamily: Platform.select({ + ios: "System", + android: "Roboto", + default: "System", + }), }, buttonDisabled: { opacity: 0.7, }, + // Styles cho phần ảnh thuyền viên + photoSection: { + alignItems: "center", + marginBottom: 20, + }, + avatarContainer: { + width: 100, + height: 100, + borderRadius: 50, + borderWidth: 2, + justifyContent: "center", + alignItems: "center", + overflow: "hidden", + position: "relative", + }, + avatar: { + width: 100, + height: 100, + borderRadius: 50, + }, + avatarPlaceholder: { + width: 100, + height: 100, + borderRadius: 50, + justifyContent: "center", + alignItems: "center", + }, + cameraIconOverlay: { + position: "absolute", + bottom: 0, + right: 0, + width: 32, + height: 32, + borderRadius: 16, + justifyContent: "center", + alignItems: "center", + borderWidth: 2, + borderColor: "#FFFFFF", + }, + photoHint: { + fontSize: 12, + marginTop: 8, + }, }); diff --git a/components/diary/TripCrewModal/CrewCard.tsx b/components/diary/TripCrewModal/CrewCard.tsx index 18b7730..c1750c4 100644 --- a/components/diary/TripCrewModal/CrewCard.tsx +++ b/components/diary/TripCrewModal/CrewCard.tsx @@ -12,7 +12,7 @@ import { Ionicons } from "@expo/vector-icons"; import { useThemeContext } from "@/hooks/use-theme-context"; import { useI18n } from "@/hooks/use-i18n"; import dayjs from "dayjs"; -import { queryCrewImage } from "@/controller/TripCrewController"; +import { queryCrewImage } from "@/controller/CrewController"; import { Buffer } from "buffer"; interface CrewCardProps { @@ -26,8 +26,12 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { const { t } = useI18n(); const person = crew.Person; - const joinedDate = crew.joined_at ? dayjs(crew.joined_at).format("DD/MM/YYYY") : "-"; - const leftDate = crew.left_at ? dayjs(crew.left_at).format("DD/MM/YYYY") : null; + const joinedDate = crew.joined_at + ? dayjs(crew.joined_at).format("DD/MM/YYYY") + : "-"; + const leftDate = crew.left_at + ? dayjs(crew.left_at).format("DD/MM/YYYY") + : null; // State for image const [imageUri, setImageUri] = useState(null); @@ -47,7 +51,9 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { const response = await queryCrewImage(person.personal_id); if (response.data) { // Convert arraybuffer to base64 - const base64 = Buffer.from(response.data as ArrayBuffer).toString("base64"); + const base64 = Buffer.from(response.data as ArrayBuffer).toString( + "base64" + ); setImageUri(`data:image/jpeg;base64,${base64}`); } else { setImageError(true); @@ -112,7 +118,12 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { {person?.name || "-"} - + {crew.role || t("diary.crew.member")} @@ -123,7 +134,11 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { {/* Phone */} - + {person?.phone || "-"} @@ -131,7 +146,11 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { {/* Personal ID */} - + {person?.personal_id || "-"} @@ -139,14 +158,22 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { {/* Joined Date */} - + {joinedDate} {/* Left Date (only show if exists) */} {leftDate && ( - + {leftDate} )} @@ -157,10 +184,17 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { {onEdit && ( onEdit(crew)} > - + {t("common.edit")} @@ -168,11 +202,23 @@ export default function CrewCard({ crew, onEdit, onDelete }: CrewCardProps) { )} {onDelete && ( onDelete(crew)} > - - + + {t("common.delete")} @@ -191,7 +237,7 @@ const styles = StyleSheet.create({ borderWidth: 1, marginBottom: 12, overflow: "hidden", - maxHeight: 150, + maxHeight: 160, }, imageSection: { width: 130, diff --git a/config/axios.ts b/config/axios.ts index 09e6be1..3e65803 100644 --- a/config/axios.ts +++ b/config/axios.ts @@ -24,7 +24,7 @@ const codeMessage = { // Tạo instance axios với cấu hình cơ bản const api: AxiosInstance = axios.create({ - baseURL: "https://sgw.gms.vn", + baseURL: "https://sgw.gms.vn", timeout: 20000, // Timeout 20 giây headers: { "Content-Type": "application/json", @@ -72,8 +72,9 @@ api.interceptors.response.use( statusText || "Unknown error"; - // Không hiển thị toast cho status 400 (validation errors) - if (status !== 400) { + // Không hiển thị toast cho status 400 (validation errors) và 404 (not found) + // 404 được xử lý riêng bởi component (ví dụ: search crew không tìm thấy) + if (status !== 400 && status !== 404) { showErrorToast(`Lỗi ${status}: ${errMsg}`); } diff --git a/constants/index.ts b/constants/index.ts index c7a7a65..0d69044 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -66,3 +66,6 @@ export const API_GET_ALL_SHIP = "/api/sgw/ships"; export const API_GET_ALL_PORT = "/api/sgw/ports"; export const API_GET_PHOTO = "/api/sgw/photo"; export const API_GET_TRIP_CREW = "/api/sgw/trips/crews"; +export const API_SEARCH_CREW = "/api/sgw/trips/crew/"; +export const API_TRIP_CREW = "/api/sgw/tripcrew"; +export const API_CREW = "/api/sgw/crew"; diff --git a/controller/CrewController.ts b/controller/CrewController.ts new file mode 100644 index 0000000..9a9ab86 --- /dev/null +++ b/controller/CrewController.ts @@ -0,0 +1,19 @@ +import { api } from "@/config"; +import { API_CREW, API_GET_PHOTO } from "@/constants"; + +export async function newCrew(body: Model.NewCrewAPIRequest) { + return api.post(API_CREW, body); +} + +export async function updateCrewInfo( + personalId: string, + body: Model.UpdateCrewAPIRequest +) { + return api.put(`${API_CREW}/${personalId}`, body); +} + +export async function queryCrewImage(personal_id: string) { + return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, { + responseType: "arraybuffer", + }); +} diff --git a/controller/TripController.ts b/controller/TripController.ts index 963ebb9..07915fa 100644 --- a/controller/TripController.ts +++ b/controller/TripController.ts @@ -8,6 +8,7 @@ import { API_GET_LAST_TRIP, API_POST_TRIP, API_PUT_TRIP, + API_TRIP_CREW, } from "@/constants"; export async function queryTrip() { diff --git a/controller/TripCrewController.ts b/controller/TripCrewController.ts index 4c5fabc..5cac7cf 100644 --- a/controller/TripCrewController.ts +++ b/controller/TripCrewController.ts @@ -1,12 +1,27 @@ import { api } from "@/config"; -import { API_GET_PHOTO, API_GET_TRIP_CREW } from "@/constants"; +import { + API_GET_PHOTO, + API_GET_TRIP_CREW, + API_SEARCH_CREW, + API_TRIP_CREW, +} from "@/constants"; export async function queryTripCrew(tripId: string) { return api.get(`${API_GET_TRIP_CREW}/${tripId}`); } -export async function queryCrewImage(personal_id: string) { - return await api.get(`${API_GET_PHOTO}/people/${personal_id}/main`, { - responseType: "arraybuffer", - }); +export async function searchCrew(personal_id: string) { + return api.get(`${API_SEARCH_CREW}/${personal_id}`); +} + +export async function newTripCrew(body: Model.NewTripCrewAPIRequest) { + return api.post(API_TRIP_CREW, body); +} + +export async function updateTripCrew(body: Model.UpdateTripCrewAPIRequest) { + return api.put(API_TRIP_CREW, body); +} + +export async function deleteTripCrew(tripId: string, personalId: string) { + return api.delete(`${API_TRIP_CREW}/${tripId}/${personalId}`); } diff --git a/controller/typings.d.ts b/controller/typings.d.ts index 31166a8..9d03d1f 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -154,6 +154,37 @@ declare namespace Model { created_at: Date; updated_at: Date; } + + // TripCrew Request Body + interface NewTripCrewAPIRequest { + trip_id: string; + personal_id: string; + role: "captain" | "crew"; + } + interface UpdateTripCrewAPIRequest { + trip_id: string; + personal_id: string; + role: "captain" | "crew"; + note: string; + } + interface NewCrewAPIRequest { + personal_id: string; + name: string; + phone: string; + email: string; + birth_date: Date; + note: string; + address: string; + } + interface UpdateCrewAPIRequest { + name: string; + phone: string; + email: string; + birth_date: Date; + //note: string; + address: string; + } + // Chi phí chuyến đi interface TripCost { type: string; diff --git a/locales/en.json b/locales/en.json index 2b1e989..6fa50c7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -154,9 +154,17 @@ "departurePort": "Departure Port", "arrivalPort": "Arrival Port", "selectPort": "Select port", + "searchPort": "Search port...", + "noPortsFound": "No ports found", "fishingGroundCodes": "Fishing Ground Codes", "fishingGroundCodesHint": "Enter fishing ground codes (comma separated)", "fishingGroundCodesPlaceholder": "e.g: 1,2,3", + "formSection": { + "basicInfo": "Basic Information", + "schedule": "Schedule & Location", + "equipment": "Fishing Gear", + "costs": "Trip Costs" + }, "autoFill": { "title": "Auto-fill data", "description": "Fill from the ship's last trip", @@ -213,8 +221,57 @@ "address": "Address", "addressPlaceholder": "Enter address", "notePlaceholder": "Enter note (optional)", - "saveError": "Unable to save. Please try again." + "saveError": "Unable to save. Please try again.", + "searchHint": "Enter ID number, system will auto-search", + "waitingSearch": "Waiting to search...", + "searching": "Searching...", + "crewFound": "Crew member found, data has been auto-filled", + "crewNotFound": "Crew member not found, please enter information to create new", + "crewAlreadyExists": "This crew member is already in the trip" } + }, + "tripDetail": { + "title": "Trip Details", + "notFound": "Trip information not found", + "basicInfo": "Basic Information", + "shipId": "VMS Ship Code", + "departureTime": "Departure Time", + "arrivalTime": "Arrival Time", + "departurePort": "Departure Port", + "arrivalPort": "Arrival Port", + "fishingGrounds": "Fishing Grounds", + "alerts": "Alert List", + "noAlerts": "No alerts", + "unknownAlert": "Unknown alert", + "confirmed": "Confirmed", + "costs": "Trip Costs", + "noCosts": "No costs", + "unknownCost": "Unknown cost", + "totalCost": "Total Cost", + "gears": "Fishing Gear List", + "noGears": "No fishing gear", + "unknownGear": "Unknown gear", + "quantity": "Quantity", + "crew": "Crew List", + "noCrew": "No crew", + "unknownCrew": "Unknown crew member", + "roleCaptain": "Captain", + "roleCrew": "Crew", + "roleEngineer": "Engineer", + "fishingLogs": "Fishing Log List", + "noFishingLogs": "No fishing logs", + "startTime": "Start", + "endTime": "End", + "startLocation": "Start Location", + "haulLocation": "Haul Location", + "catchInfo": "Catch Info", + "species": "species", + "unknownFish": "Unknown fish", + "more": "more species", + "logStatusPending": "Pending", + "logStatusActive": "Active", + "logStatusCompleted": "Completed", + "logStatusUnknown": "Unknown" } }, "trip": { diff --git a/locales/vi.json b/locales/vi.json index 95a0206..77ca41c 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -221,7 +221,13 @@ "address": "Địa chỉ", "addressPlaceholder": "Nhập địa chỉ", "notePlaceholder": "Nhập ghi chú (nếu có)", - "saveError": "Không thể lưu thông tin. Vui lòng thử lại." + "saveError": "Không thể lưu thông tin. Vui lòng thử lại.", + "searchHint": "Nhập số định danh, hệ thống sẽ tự động tìm kiếm", + "waitingSearch": "Đang chờ tìm kiếm...", + "searching": "Đang tìm kiếm...", + "crewFound": "Đã tìm thấy thuyền viên, dữ liệu đã được điền tự động", + "crewNotFound": "Không tìm thấy thuyền viên, vui lòng nhập thông tin để tạo mới", + "crewAlreadyExists": "Thuyền viên này đã có trong chuyến đi" } }, "tripDetail": { diff --git a/package-lock.json b/package-lock.json index 859dd56..ebf0188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "expo-constants": "~18.0.10", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.8", "expo-localization": "~17.0.7", "expo-router": "~6.0.13", @@ -8545,6 +8546,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.7", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz", diff --git a/package.json b/package.json index 4593a77..c296d61 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "expo-constants": "~18.0.10", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", + "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.8", "expo-localization": "~17.0.7", "expo-router": "~6.0.13",