Files
project_it207_client/app/order-detail.tsx
2025-11-18 16:18:43 +07:00

509 lines
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import api from '../services/api';
interface OrderDetail {
orderId: number;
userId: number;
userName: string;
totalPrice: number;
orderStatus: string;
shippingAddress: string;
createdAt: string;
updatedAt: string;
orderItems: {
orderItemId: number;
productId: number;
productName: string;
quantity: number;
price: number;
subtotal: number;
}[];
}
interface Timeline {
status: string;
timestamp: string | null;
completed: boolean;
}
const STATUS_COLORS: Record<string, string> = {
PENDING: '#FFA726',
CONFIRMED: '#42A5F5',
SHIPPING: '#AB47BC',
DELIVERED: '#66BB6A',
CANCELLED: '#EF5350',
};
const STATUS_LABELS: Record<string, string> = {
PENDING: 'Chờ xác nhận',
CONFIRMED: 'Đã xác nhận',
SHIPPING: 'Đang giao hàng',
DELIVERED: 'Đã giao hàng',
CANCELLED: 'Đã hủy',
};
export default function OrderDetailScreen() {
const router = useRouter();
const params = useLocalSearchParams();
const orderId = params.id;
const [order, setOrder] = useState<OrderDetail | null>(null);
const [timeline, setTimeline] = useState<Timeline[]>([]);
const [loading, setLoading] = useState(true);
const [cancelling, setCancelling] = useState(false);
useEffect(() => {
if (orderId) {
loadOrderDetail();
loadOrderProgress();
}
}, [orderId]);
/** 🔹 Load chi tiết đơn hàng */
const loadOrderDetail = async () => {
try {
const res = await api.get(`/orders/${orderId}`, {
requireAuth: true,
});
// Fix: Safely check 'res' type before accessing properties
if (res && typeof res === 'object' && 'success' in res && (res as any).success) {
setOrder((res as any).data);
}
} catch (error) {
console.error('Load order detail error:', error);
Alert.alert('Lỗi', 'Không thể tải chi tiết đơn hàng.');
} finally {
setLoading(false);
}
};
/** 🔹 Load tiến trình đơn hàng */
const loadOrderProgress = async () => {
try {
const res = await api.get(`/orders/${orderId}/progress`, {
requireAuth: true,
});
// Fix: safely check 'res' type before accessing properties
if (res && typeof res === 'object' && 'success' in res && (res as any).success) {
setTimeline((res as any).data.timeline);
}
} catch (error) {
console.error('Load order progress error:', error);
}
};
/** 🔹 Hủy đơn hàng */
const handleCancelOrder = () => {
Alert.alert(
'Hủy đơn hàng',
'Bạn có chắc chắn muốn hủy đơn hàng này?',
[
{ text: 'Không', style: 'cancel' },
{ text: 'Có', onPress: cancelOrder, style: 'destructive' },
]
);
};
const cancelOrder = async () => {
setCancelling(true);
try {
const res = await api.put(`/orders/${orderId}/cancel`, undefined, {
requireAuth: true,
});
// Fix: 'res' is of type 'unknown', so first safely cast/type guard
if (res && typeof res === 'object' && 'success' in res && (res as any).success) {
Alert.alert('Thành công', 'Đơn hàng đã được hủy');
loadOrderDetail();
loadOrderProgress();
} else {
let message = 'Không thể hủy đơn hàng';
if (res && typeof res === 'object' && 'message' in res) {
// @ts-ignore
message = res.message || message;
}
Alert.alert('Lỗi', message);
}
} catch (error) {
Alert.alert('Lỗi', 'Không thể hủy đơn hàng');
} finally {
setCancelling(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FF6B6B" />
</View>
);
}
if (!order) {
return (
<View style={styles.errorContainer}>
<Text>Không tìm thấy đơn hàng</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color="#000" />
</TouchableOpacity>
<Text style={styles.headerTitle}>Chi tiết đơn hàng</Text>
<View style={{ width: 24 }} />
</View>
{/* Order status */}
<View style={styles.statusSection}>
<View
style={[
styles.statusBadge,
{ backgroundColor: STATUS_COLORS[order.orderStatus] },
]}
>
<Text style={styles.statusText}>
{STATUS_LABELS[order.orderStatus]}
</Text>
</View>
<Text style={styles.orderId}>Đơn hàng #{order.orderId}</Text>
<Text style={styles.orderDate}>
Đt hàng: {formatDate(order.createdAt)}
</Text>
</View>
{/* Timeline */}
{order.orderStatus !== 'CANCELLED' && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Tiến đ đơn hàng</Text>
{timeline.map((step, idx) => (
<View key={idx} style={styles.timelineItem}>
<View style={styles.timelineLeft}>
<View
style={[
styles.timelineDot,
step.completed && styles.timelineDotActive,
]}
>
{step.completed && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
{idx < timeline.length - 1 && (
<View
style={[
styles.timelineLine,
step.completed && styles.timelineLineActive,
]}
/>
)}
</View>
<View style={styles.timelineRight}>
<Text
style={[
styles.timelineStatus,
step.completed && styles.timelineStatusActive,
]}
>
{STATUS_LABELS[step.status]}
</Text>
{step.timestamp && (
<Text style={styles.timelineDate}>
{formatDate(step.timestamp)}
</Text>
)}
</View>
</View>
))}
</View>
)}
{/* Shipping address */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
<Ionicons name="location" size={18} color="#333" /> Đa chỉ giao hàng
</Text>
<Text style={styles.addressText}>{order.shippingAddress}</Text>
</View>
{/* Items */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
<Ionicons name="cart" size={18} color="#333" /> Sản phẩm
</Text>
{order.orderItems.map((item) => (
<View key={item.orderItemId} style={styles.productItem}>
<View style={styles.productInfo}>
<Text style={styles.productName}>{item.productName}</Text>
<Text style={styles.productQuantity}>x{item.quantity}</Text>
</View>
<View style={styles.productPrices}>
<Text style={styles.productPrice}>
{item.price.toLocaleString('vi-VN')}
</Text>
<Text style={styles.productSubtotal}>
{item.subtotal.toLocaleString('vi-VN')}
</Text>
</View>
</View>
))}
</View>
{/* Summary */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Thanh toán</Text>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Tạm tính</Text>
<Text style={styles.summaryValue}>
{order.totalPrice.toLocaleString('vi-VN')}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Phí vận chuyển</Text>
<Text style={styles.summaryValue}>0</Text>
</View>
<View style={[styles.summaryRow, styles.totalRow]}>
<Text style={styles.totalLabel}>Tổng cộng</Text>
<Text style={styles.totalValue}>
{order.totalPrice.toLocaleString('vi-VN')}
</Text>
</View>
</View>
{/* Cancel Order */}
{(order.orderStatus === 'PENDING' ||
order.orderStatus === 'CONFIRMED') && (
<TouchableOpacity
style={[styles.cancelButton, cancelling && styles.buttonDisabled]}
onPress={handleCancelOrder}
disabled={cancelling}
>
{cancelling ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.cancelButtonText}>Hủy đơn hàng</Text>
)}
</TouchableOpacity>
)}
<View style={{ height: 20 }} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#E0E0E0',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
},
statusSection: {
backgroundColor: '#fff',
padding: 20,
alignItems: 'center',
marginTop: 10,
},
statusBadge: {
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 20,
marginBottom: 12,
},
statusText: {
color: '#fff',
fontSize: 14,
fontWeight: 'bold',
},
orderId: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 4,
},
orderDate: {
fontSize: 14,
color: '#666',
},
section: {
backgroundColor: '#fff',
marginTop: 10,
padding: 16,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 12,
},
timelineItem: {
flexDirection: 'row',
marginBottom: 8,
},
timelineLeft: {
alignItems: 'center',
marginRight: 12,
},
timelineDot: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#E0E0E0',
alignItems: 'center',
justifyContent: 'center',
},
timelineDotActive: {
backgroundColor: '#4CAF50',
},
timelineLine: {
width: 2,
flex: 1,
backgroundColor: '#E0E0E0',
marginTop: 4,
},
timelineLineActive: {
backgroundColor: '#4CAF50',
},
timelineRight: {
flex: 1,
paddingBottom: 16,
},
timelineStatus: {
fontSize: 14,
color: '#999',
marginBottom: 4,
},
timelineStatusActive: {
color: '#333',
fontWeight: 'bold',
},
timelineDate: {
fontSize: 12,
color: '#999',
},
addressText: {
fontSize: 14,
color: '#333',
lineHeight: 20,
},
productItem: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
productInfo: {
flex: 1,
marginRight: 12,
},
productName: {
fontSize: 14,
marginBottom: 4,
},
productQuantity: {
fontSize: 12,
color: '#666',
},
productPrices: {
alignItems: 'flex-end',
},
productPrice: {
fontSize: 12,
color: '#666',
marginBottom: 4,
},
productSubtotal: {
fontSize: 14,
fontWeight: 'bold',
color: '#FF6B6B',
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
summaryLabel: {
fontSize: 14,
color: '#666',
},
summaryValue: {
fontSize: 14,
},
totalRow: {
borderTopWidth: 1,
borderTopColor: '#E0E0E0',
marginTop: 8,
paddingTop: 12,
},
totalLabel: {
fontSize: 16,
fontWeight: 'bold',
},
totalValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#FF6B6B',
},
cancelButton: {
backgroundColor: '#EF5350',
margin: 16,
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonDisabled: {
opacity: 0.6,
},
cancelButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: 'bold',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});