Chỉnh sửa giao diện Tour Dashboard
This commit is contained in:
+125
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user