Chỉnh sửa giao diện Tour Dashboard

This commit is contained in:
2026-06-13 22:16:27 +07:00
parent 5e3f10631f
commit c7db2c3ed8
11 changed files with 244 additions and 47 deletions
+125 -21
View File
@@ -15,21 +15,37 @@ import {
Quote,
Plus,
List,
Map as MapIconLucide
Map as MapIconLucide,
MapPin,
Flag
} from 'lucide-react';
import L from 'leaflet';
// Định nghĩa kiểu dữ liệu cho Địa điểm để khớp với Schema Prisma
type LocationType = 'MOVE' | 'VISIT' | 'REST' | 'EAT';
// Menu ngữ cảnh cho bản đồ
const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.LatLng) => void }) => {
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
const { legs } = useTourStore();
const { legs, setMapCenter } = useTourStore();
useMapEvents({
contextmenu: (e) => {
// Ngăn menu mặc định của trình duyệt hiện lên.
// Điều này là cần thiết để menu tùy chỉnh của Leaflet có thể tương tác được.
// Nếu không có, menu của trình duyệt sẽ đè lên và chặn các sự kiện click.
if (e.originalEvent) {
L.DomEvent.preventDefault(e.originalEvent);
}
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
},
click: () => setMenuPos(null),
dragstart: () => setMenuPos(null)
dragstart: () => setMenuPos(null),
moveend: (e) => {
const map = e.target;
const center = map.getCenter();
setMapCenter([center.lat, center.lng]);
}
});
if (!menuPos) return null;
@@ -40,10 +56,10 @@ const MapContextMenu = ({ onAction }: { onAction: (action: string, latlng: L.Lat
style={{ top: menuPos.y, left: menuPos.x }}
>
<button onClick={() => { onAction('START', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" /> Bắt đu từ đây
<div className="w-2 h-2 rounded-full bg-blue-600" /> Bắt đu từ đây
</button>
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-red-500" /> Kết thúc đây
<button onClick={() => { onAction('END', menuPos.latlng); setMenuPos(null); }} className="w-full text-left px-4 py-2 hover:bg-green-50 text-sm font-bold text-gray-700 flex items-center gap-2 border-b border-gray-50">
<div className="w-2 h-2 rounded-full bg-green-500" /> Kết thúc đây
</button>
<div className="px-4 py-2 text-[10px] font-black text-gray-400 uppercase tracking-widest">Thêm vào chặng</div>
{legs.map(leg => (
@@ -63,7 +79,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs, addLocation } = useTourStore();
const { currentTour, fetchTour, fetchPublicTours, publicTours, userRole, legs, addLocation, mapCenter } = useTourStore();
useEffect(() => {
const loadData = async () => {
@@ -82,33 +98,61 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
}
}, [publicTours, currentTour, fetchTour]);
// Hàm xử lý các hành động từ Context Menu của bản đồ
const handleMapAction = async (action: string, latlng: L.LatLng) => {
if (!currentTour || legs.length === 0) return;
// Đảm bảo có tour và ít nhất một chặng để ghim
const currentLegs = useTourStore.getState().legs;
if (!currentTour || currentLegs.length === 0) {
alert("Tour chưa có chặng nào. Vui lòng tạo chặng trước khi thêm địa điểm.");
return;
}
let targetLegId = legs[0].id; // Mặc định là chặng đầu
let targetLegId = currentLegs[0].id; // Mặc định là chặng đầu
let defaultName = "Địa điểm mới";
let locationType: LocationType = 'VISIT'; // Mặc định là tham quan
if (action === 'START') defaultName = "Điểm bắt đầu";
if (action === 'START') {
defaultName = "Điểm bắt đầu";
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
}
if (action === 'END') {
targetLegId = legs[legs.length - 1].id;
targetLegId = currentLegs[currentLegs.length - 1].id;
defaultName = "Điểm kết thúc";
locationType = 'MOVE'; // Điểm kết thúc cũng liên quan đến di chuyển
}
if (action.startsWith('ADD_TO_LEG_')) {
targetLegId = action.replace('ADD_TO_LEG_', '');
// Loại địa điểm mặc định vẫn là VISIT nếu thêm vào chặng cụ thể
}
const name = window.prompt("Nhập tên địa điểm:", defaultName);
// Tự động lấy tên địa điểm từ tọa độ (Reverse Geocoding)
let detectedName = "";
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
const data = await res.json();
detectedName = data.display_name?.split(',')[0] || "";
} catch (e) {}
const name = window.prompt("Xác nhận tên địa điểm:", detectedName || defaultName);
if (name) {
await addLocation(currentTour.id, {
name,
address: '', // Gửi chuỗi rỗng cho địa chỉ nếu không có
latitude: latlng.lat,
longitude: latlng.lng,
legId: targetLegId,
type: 'VISIT'
type: locationType as any,
plannedStart: null, // Gửi null cho thời gian nếu không có
plannedEnd: null, // Gửi null cho thời gian nếu không có
});
}
};
// Xác định Điểm xuất phát và Điểm kết thúc hiển thị dưới widget tài chính
const startPoint = legs[0]?.locations[0];
const lastLeg = legs[legs.length - 1];
const endPoint = lastLeg?.locations[lastLeg.locations.length - 1];
// Định nghĩa các tab dựa trên quyền hạn (RBAC) từ store
const tabs = [
{ id: 'plan', label: 'Lộ trình', icon: MapIcon, visible: true },
@@ -150,17 +194,27 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
{/* Tour Header Info */}
<div className="relative h-64 w-full bg-blue-900">
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
<img
src={tourInfo.coverImage}
className="w-full h-full object-cover opacity-60"
className="absolute inset-0 w-full h-full object-cover opacity-60"
alt="Tour Cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-16 left-0 right-0 p-6 text-white">
<div className="max-w-2xl mx-auto space-y-2">
<div className="relative z-10 p-6 text-white pt-28 pb-20">
<div className="max-w-2xl mx-auto space-y-4">
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
{/* Dòng tóm tắt Lộ trình */}
<div className="mt-3 text-sm font-bold text-blue-100 bg-blue-800/30 backdrop-blur-sm px-4 py-2 rounded-xl border border-white/5 inline-block max-w-full truncate">
<span className="text-white/60 mr-1">Lộ trình:</span>
<span>Điểm xuất phát: </span>
<span className="text-white">{startPoint?.name || '...'}</span>
<span className="mx-2 text-white/40">-</span>
<span>Điểm kết thúc: </span>
<span className="text-white">{endPoint?.name || '...'}</span>
</div>
<div className="flex flex-wrap gap-4 text-sm font-medium opacity-90">
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
<Calendar className="w-4 h-4 mr-1.5" />
@@ -172,6 +226,8 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
</div>
{/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4">
<div className="flex -space-x-3">
@@ -216,6 +272,32 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
</div>
{/* Khối hiển thị Điểm đầu & Điểm cuối (Dưới Financial Quick-View) */}
<div className="max-w-2xl mx-auto mt-4 px-4 grid grid-cols-1 sm:grid-cols-2 gap-3 animate-in fade-in slide-in-from-top-2 duration-500">
{startPoint && (
<div className="bg-white p-4 rounded-2xl border border-blue-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center text-blue-600 shadow-inner">
<MapPin className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-0.5">Điểm xuất phát</p>
<p className="text-sm font-bold text-gray-800 truncate">{startPoint.name}</p>
</div>
</div>
)}
{endPoint && (
<div className="bg-white p-4 rounded-2xl border border-green-50 flex items-center gap-3 shadow-sm hover:shadow-md transition-shadow">
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center text-green-600 shadow-inner">
<Flag className="w-5 h-5" />
</div>
<div className="overflow-hidden">
<p className="text-[10px] font-black text-green-400 uppercase tracking-widest mb-0.5">Điểm kết thúc</p>
<p className="text-sm font-bold text-gray-800 truncate">{endPoint.name}</p>
</div>
</div>
)}
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
{/* Tab Switcher */}
@@ -262,7 +344,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
<ItineraryTimeline />
) : (
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
<MapContainer center={[10.7769, 106.7009]} zoom={13} className="h-full w-full">
<MapContainer center={mapCenter} zoom={13} className="h-full w-full">
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<MapContextMenu onAction={handleMapAction} />
@@ -272,14 +354,36 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
return <Polyline key={leg.id} positions={positions as any} color="#3b82f6" weight={3} dashArray="5, 10" />;
})}
{legs.flatMap(l => l.locations).map((loc: any) => (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]}>
{legs.flatMap(l => l.locations).map((loc: any) => {
const isStart = startPoint?.id === loc.id;
const isEnd = endPoint?.id === loc.id;
let customIcon = undefined;
if (isStart) {
customIcon = L.divIcon({
className: 'custom-marker',
html: `<div class="w-5 h-5 bg-blue-600 rounded-full border-2 border-white shadow-lg"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
});
} else if (isEnd) {
customIcon = L.divIcon({
className: 'custom-marker',
html: `<div class="w-5 h-5 bg-green-500 rounded-full border-2 border-white shadow-lg"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
});
}
return (
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={customIcon}>
<Popup>
<div className="font-bold">{loc.name}</div>
<div className="text-xs text-gray-500">{loc.type}</div>
</Popup>
</Marker>
))}
);
})}
</MapContainer>
<div className="absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white">
Mẹo: Nhấn giữ (Mobile) hoặc Chuột phải đ ghim đa điểm