509 lines
13 KiB
TypeScript
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',
|
|
},
|
|
}); |