Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6026c2227a | |||
| 1eed9b00de | |||
| 1709638bfd | |||
| 37b5d14d7e | |||
| 2fabdb79df | |||
| 00554224a1 | |||
| 88b2182789 | |||
| 989d60643b | |||
| 1772ced959 | |||
| c005009da2 | |||
| bfd18e05dd | |||
| 29d39ae7b0 | |||
| 047170d4be | |||
| fc96ee9eb8 | |||
| 0a584a13c7 | |||
| f78c72ad60 | |||
| 8cca4a83ca | |||
| bc49e081c6 | |||
| 9e26aabce6 | |||
| 3e1a5db1a6 | |||
| baa83aad8a | |||
| 302a82e887 | |||
| 63da413b3c | |||
| 02c493f7e0 | |||
| b939fc959d | |||
| 5464d90948 | |||
| c5530f36df | |||
| dcaf71032c | |||
| aec9112064 | |||
| 8dac79fc4b | |||
| 75cf96898c | |||
| f52580717f | |||
| 879f20985c | |||
| 4edfd0eb59 | |||
| 55f0a8e775 | |||
| 7812d1395b | |||
| c9ef98b2ff | |||
| 71f4aa9ecd | |||
| 34e2af8825 | |||
| 4716b841dc | |||
| 967f6b4f6a | |||
| c6341aa12f | |||
| 7cc724a133 | |||
| 8ff09eeeaf | |||
| 914a9cf243 |
@@ -0,0 +1,8 @@
|
||||
name: Local Config
|
||||
version: 1.0.0
|
||||
schema: v1
|
||||
models:
|
||||
- name: Autodetect
|
||||
provider: lmstudio
|
||||
model: AUTODETECT
|
||||
apiBase: http://192.168.1.12:1234/v1/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://app.kilo.ai/config.json"
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LandingPage } from './LandingPage.js';
|
||||
import { TourDetailPage } from './TourDetailPage.js';
|
||||
import { ExploreMap } from './ExploreMap.js';
|
||||
import { SignupPage } from './SignupPage.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
|
||||
const App = () => {
|
||||
type View = 'landing' | 'explore' | 'detail' | 'signup';
|
||||
const [view, setView] = useState<View>('landing');
|
||||
const [isInitialSetup, setIsInitialSetup] = useState(false);
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [isUserLoaded, setIsUserLoaded] = useState(false); // Trạng thái để biết user đã được load từ localStorage chưa
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
|
||||
useEffect(() => {
|
||||
// Tự động xác định địa chỉ IP của Backend dựa trên hostname hiện tại
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
|
||||
// Khôi phục phiên đăng nhập từ localStorage
|
||||
const savedUser = localStorage.getItem('user');
|
||||
if (savedUser) {
|
||||
const parsedUser = JSON.parse(savedUser);
|
||||
setUser(parsedUser);
|
||||
}
|
||||
setIsUserLoaded(true); // Đánh dấu user đã được load
|
||||
|
||||
// Kiểm tra xem hệ thống đã được cài đặt chưa
|
||||
fetch(`${API_BASE}/api/v1/auth/status`)
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => setIsInitialSetup(!!data.isInitialSetup))
|
||||
.catch(() => setIsInitialSetup(false));
|
||||
}, []); // Chạy một lần khi component mount
|
||||
|
||||
// Effect để xử lý chuyển hướng nếu user đã đăng nhập và đang ở trang landing
|
||||
useEffect(() => {
|
||||
if (isUserLoaded && user && view === 'landing') {
|
||||
setView('explore');
|
||||
}
|
||||
}, [isUserLoaded, user, view]);
|
||||
|
||||
const handleLoginSuccess = (userData: any) => {
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
setView('landing');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{view === 'landing' && (
|
||||
<LandingPage
|
||||
isInitialSetup={isInitialSetup}
|
||||
onContinue={() => setView('explore')}
|
||||
onGoToSignup={() => setView('signup')}
|
||||
onGoToMap={() => setView('explore')}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'signup' && (
|
||||
<SignupPage
|
||||
onBack={() => setView('landing')}
|
||||
onSuccess={() => setView('landing')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'explore' && (
|
||||
<ExploreMap
|
||||
onBack={() => setView('landing')}
|
||||
onLogout={user ? handleLogout : undefined}
|
||||
user={user}
|
||||
onViewTour={(id) => {
|
||||
fetchTour(id);
|
||||
setView('detail');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'detail' && (
|
||||
<TourDetailPage onBack={() => setView('explore')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Wallet, Users, Info } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
|
||||
export const ExpenseManager = () => {
|
||||
const { legs } = useTourStore();
|
||||
const [adults, setAdults] = useState(2);
|
||||
const [children, setChildren] = useState(1);
|
||||
const [discount, setDiscount] = useState(30);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalAmount = legs.reduce((acc, leg) =>
|
||||
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
|
||||
);
|
||||
|
||||
const childRateFactor = 1 - (discount / 100);
|
||||
const weightedCount = adults + (children * childRateFactor);
|
||||
const adultPrice = totalAmount / weightedCount;
|
||||
const childPrice = adultPrice * childRateFactor;
|
||||
|
||||
return {
|
||||
total: totalAmount,
|
||||
adultPrice: Math.round(adultPrice),
|
||||
childPrice: Math.round(childPrice)
|
||||
};
|
||||
}, [legs, adults, children, discount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6 text-blue-600">
|
||||
<Users className="w-5 h-5" />
|
||||
<h3 className="font-bold">Cấu hình thành viên</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
|
||||
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
|
||||
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
|
||||
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
|
||||
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
|
||||
</div>
|
||||
<Wallet className="w-8 h-8 opacity-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
||||
<div>
|
||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
|
||||
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
|
||||
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
||||
<Info className="w-4 h-4" />
|
||||
<p>Chi phí được tự động tính toán dựa trên hóa đơn của các chặng.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2 } from 'lucide-react';
|
||||
import { UserManagementModal } from './UserManagementModal.js';
|
||||
import { CreateTourModal } from './CreateTourModal.js';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||
function RecenterMap({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView(position, map.getZoom());
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||
function MapTracker() {
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
useMapEvents({
|
||||
moveend: (e) => {
|
||||
const map = e.target;
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const coords: [number, number] = [center.lat, center.lng];
|
||||
|
||||
setMapCenter(coords);
|
||||
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
|
||||
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void }) => {
|
||||
// Thêm fetchTour vào destructuring từ store
|
||||
const { publicTours, fetchPublicTours, fetchTour, setMapCenter } = useTourStore();
|
||||
|
||||
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved); } catch (e) { return null; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPublicTours();
|
||||
|
||||
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
||||
if (!initialViewState) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPos(posArray);
|
||||
setMapCenter(posArray);
|
||||
},
|
||||
() => console.log("Không thể lấy vị trí người dùng")
|
||||
);
|
||||
} else {
|
||||
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full relative">
|
||||
{/* Nút quay lại */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
|
||||
{/* Nút đăng xuất - Chỉ hiển thị khi có user login */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Đăng xuất</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Quản lý hệ thống</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Tạo Tour mới</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Header Overlay */}
|
||||
<div className="absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block">
|
||||
<div className="flex items-center gap-2">
|
||||
<Navigation className="w-4 h-4 text-blue-600" />
|
||||
<span className="font-bold text-gray-800">Đang khám phá khu vực của bạn</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapContainer
|
||||
center={userPos}
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap contributors'
|
||||
/>
|
||||
|
||||
{/* Theo dõi di chuyển bản đồ */}
|
||||
<MapTracker />
|
||||
|
||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||
<RecenterMap position={userPos} />
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{publicTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
if (!startLoc) return null;
|
||||
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
|
||||
return (
|
||||
<React.Fragment key={tour.id}>
|
||||
{/* Tour Marker - Bong bóng chứa thumbnail. Click chuyển vào Dashboard */}
|
||||
<Marker
|
||||
position={[startLoc.latitude, startLoc.longitude]}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id)
|
||||
}}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<div class="relative group">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
})}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
{/* Admin Modal */}
|
||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||
|
||||
{/* Create Tour Modal */}
|
||||
<CreateTourModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={(tour) => {
|
||||
fetchTour(tour.id);
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,644 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ItineraryTimeline } from './ItineraryTimeline.js';
|
||||
import { ExpenseManager } from './ExpenseManager.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { AddLocationModal } from './AddLocationModal.js';
|
||||
import { AddMemberModal } from './AddMemberModal.js';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import { useMap } from 'react-leaflet';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
Wallet,
|
||||
Image as ImageIcon,
|
||||
Calendar,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
Settings,
|
||||
Quote,
|
||||
Plus,
|
||||
List,
|
||||
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';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet cho môi trường Vite
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Định nghĩa các static icons để ngăn chặn việc khởi tạo lại liên tục gây crash khi unmount
|
||||
const START_ICON = L.divIcon({
|
||||
className: 'custom-marker-s',
|
||||
html: `<div class="w-6 h-6 bg-blue-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">S</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
|
||||
const END_ICON = L.divIcon({
|
||||
className: 'custom-marker-e',
|
||||
html: `<div class="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-[10px] font-black text-white">E</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
|
||||
const VISIT_ICON = L.divIcon({
|
||||
className: 'custom-marker-v',
|
||||
html: `<div class="w-4 h-4 bg-indigo-500 rounded-full border-2 border-white shadow-md"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
|
||||
// Component Helper để tự động điều chỉnh khung nhìn bản đồ bao phủ toàn bộ lộ trình
|
||||
const MapTourBounds = ({ locations }: { locations: any[] }) => {
|
||||
const map = useMap();
|
||||
// Tạo một key dựa trên giá trị tọa độ để tránh chạy lại khi chỉ thay đổi tham chiếu mảng
|
||||
const locKey = useMemo(() => JSON.stringify(locations.map(l => [l.latitude, l.longitude])), [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (locations.length > 0) {
|
||||
const bounds = L.latLngBounds(locations.map(l => [l.latitude, l.longitude]));
|
||||
if (locations.length === 1) {
|
||||
// Chỉ thực hiện nếu bản đồ chưa ở đúng vị trí (tránh trigger moveend liên tục)
|
||||
map.setView([locations[0].latitude, locations[0].longitude], 15, { animate: true });
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}
|
||||
}, [locKey, map]);
|
||||
|
||||
return null;
|
||||
};
|
||||
// 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 menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sử dụng selector để tránh re-render khi mapCenter thay đổi
|
||||
const legs = useTourStore(state => state.legs);
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
|
||||
useMapEvents({
|
||||
contextmenu: (e) => {
|
||||
// Ngăn menu mặc định của trình duyệt hiện lên.
|
||||
if (e.originalEvent) {
|
||||
L.DomEvent.preventDefault(e.originalEvent);
|
||||
L.DomEvent.stopPropagation(e.originalEvent);
|
||||
}
|
||||
|
||||
console.log(`[MAP] Context menu triggered at: ${e.latlng.lat}, ${e.latlng.lng}`);
|
||||
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||
},
|
||||
|
||||
moveend: (e) => {
|
||||
const map = e.target;
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const coords: [number, number] = [center.lat, center.lng];
|
||||
|
||||
// Chỉ cập nhật store nếu tọa độ thay đổi đáng kể (> 0.0001) để tránh loop
|
||||
const currentStored = useTourStore.getState().mapCenter;
|
||||
const diff = Math.abs(currentStored[0] - coords[0]) + Math.abs(currentStored[1] - coords[1]);
|
||||
|
||||
if (diff > 0.0001) {
|
||||
setMapCenter(coords);
|
||||
}
|
||||
|
||||
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||
},
|
||||
click: () => setMenuPos(null),
|
||||
dragstart: () => setMenuPos(null),
|
||||
});
|
||||
|
||||
// Ngăn chặn các sự kiện của bản đồ khi tương tác với menu
|
||||
useEffect(() => {
|
||||
if (menuPos && menuRef.current) {
|
||||
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||
L.DomEvent.disableScrollPropagation(menuRef.current);
|
||||
}
|
||||
}, [menuPos]);
|
||||
|
||||
if (!menuPos) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||
style={{ top: menuPos.y, left: menuPos.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<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-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-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-600" /> 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 => (
|
||||
<button
|
||||
key={leg.id}
|
||||
onClick={() => { onAction(`ADD_TO_LEG_${leg.id}`, menuPos.latlng); setMenuPos(null); }}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-sm font-medium text-gray-600 truncate"
|
||||
>
|
||||
Chặng {leg.sequence}: {leg.note || 'Không có ghi chú'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||
|
||||
// Khôi phục vị trí và mức zoom từ localStorage
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved); } catch (e) { return null; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// SỬA LỖI: Sử dụng các selectors riêng lẻ để tránh re-render trang khi mapCenter trong store thay đổi
|
||||
// Gom các store actions/state lại để tối ưu hóa re-render
|
||||
const {
|
||||
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
||||
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
||||
updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember
|
||||
} = useTourStore();
|
||||
|
||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
|
||||
// Memoize danh sách tọa độ để MapTourBounds không bị trigger thừa
|
||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialViewState) {
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
const loadData = async () => {
|
||||
// Nếu chưa có tour nào trong store, thử tải danh sách public trước
|
||||
if (publicTours.length === 0) {
|
||||
await fetchPublicTours();
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Lấy tour đầu tiên từ danh sách khám phá để hiển thị demo
|
||||
if (publicTours.length > 0 && !currentTour) {
|
||||
fetchTour(publicTours[0].id);
|
||||
}
|
||||
}, [publicTours, currentTour, fetchTour]);
|
||||
|
||||
// Hàm xử lý khai báo số chặng
|
||||
const handleDeclareLegs = async () => {
|
||||
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||
const count = parseInt(countStr || "0");
|
||||
if (count > 0 && currentTour) {
|
||||
await initializeLegs(currentTour.id, count);
|
||||
}
|
||||
};
|
||||
|
||||
// Hàm xử lý các hành động từ Context Menu của bản đồ
|
||||
const handleMapAction = async (action: string, latlng: L.LatLng) => {
|
||||
// Bước 1: Lấy tọa độ (lat, lng) tại vị trí click (đã nhận qua tham số latlng)
|
||||
console.log(`[FRONTEND] Triggered ${action} at:`, { lat: latlng.lat, lng: latlng.lng });
|
||||
|
||||
// Đả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 (Leg) trước khi thực hiện.");
|
||||
return;
|
||||
}
|
||||
|
||||
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";
|
||||
locationType = 'MOVE'; // Điểm bắt đầu thường liên quan đến di chuyển
|
||||
}
|
||||
if (action === 'END') {
|
||||
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ể
|
||||
}
|
||||
|
||||
// Bước 2: Gửi request đến dịch vụ bản đồ để phân tích tọa độ thành tên địa điểm cụ thể
|
||||
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();
|
||||
const addr = data.address;
|
||||
// Ưu tiên lấy tên Location/Tòa nhà/Tên đường, bỏ qua Tỉnh/Thành phố nếu có thông tin chi tiết hơn
|
||||
detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||
addr.road || addr.neighbourhood || addr.suburb ||
|
||||
data.display_name?.split(',')[0] || "";
|
||||
console.log(`[FRONTEND] Geocoding Result: "${detectedName}"`);
|
||||
} catch (e) {
|
||||
console.warn("[FRONTEND] Reverse Geocoding failed:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'START') {
|
||||
// Bước 3: Mutate State & UI - Đặt startLocationName = resolvedPlaceName
|
||||
const resolvedPlaceName = detectedName || "Điểm xuất phát";
|
||||
console.log(`[FRONTEND] Updating START point to: ${resolvedPlaceName} at`, latlng);
|
||||
await updateTourStartPoint(currentTour.id, {
|
||||
name: resolvedPlaceName,
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
});
|
||||
console.log("[FRONTEND] START point updated successfully.");
|
||||
// Giao diện Top Banner và Ghim màu xanh sẽ tự động cập nhật
|
||||
// khi store fetch lại dữ liệu tour và re-render.
|
||||
} else if (action === 'END') {
|
||||
// Bước 2: Thiết lập Điểm kết thúc
|
||||
const finalName = detectedName || "Điểm kết thúc";
|
||||
await updateTourEndPoint(currentTour.id, {
|
||||
name: finalName,
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
});
|
||||
} else {
|
||||
// Đối với việc thêm địa điểm vào chặng, vẫn sử dụng Prompt để người dùng đặt tên theo ý muốn
|
||||
const name = window.prompt("Xác nhận tên địa điểm tham quan:", detectedName || defaultName);
|
||||
if (!name) return;
|
||||
|
||||
await addLocation(currentTour.id, {
|
||||
name,
|
||||
address: '',
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
legId: targetLegId,
|
||||
type: locationType as any,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Xử lý lỗi từ API (Ví dụ: Tour chưa có chặng nào)
|
||||
if (error.message.includes('Không tìm thấy chặng')) {
|
||||
alert("Lỗi: Bạn cần tạo ít nhất một Chặng (Leg) trước khi xác định điểm Bắt đầu/Kết thúc.");
|
||||
} else {
|
||||
alert("Đã xảy ra lỗi: " + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 },
|
||||
{ id: 'expense', label: 'Chi phí', icon: Wallet, visible: ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '') },
|
||||
{ id: 'photo', label: 'Ảnh', icon: ImageIcon, visible: true },
|
||||
{ id: 'settings', label: 'Cài đặt', icon: Settings, visible: userRole === 'OWNER' },
|
||||
].filter(t => t.visible);
|
||||
|
||||
const hasFinanceAccess = ['OWNER', 'MANAGER', 'MEMBER'].includes(userRole || '');
|
||||
|
||||
const travelQuotes = [
|
||||
"Đừng nghe họ nói, hãy tự mình đi xem.",
|
||||
"Thế giới là một cuốn sách, và ai không đi du lịch thì chỉ mới đọc được một trang.",
|
||||
"Hành trình ngàn dặm bắt đầu từ một bước chân.",
|
||||
"Đi là để trở về, nhưng với một tâm hồn mới."
|
||||
];
|
||||
|
||||
const randomQuote = useMemo(() => travelQuotes[Math.floor(Math.random() * travelQuotes.length)], []);
|
||||
|
||||
const tourInfo = {
|
||||
title: currentTour?.title || "Hành trình khám phá TP.HCM",
|
||||
date: currentTour?.startDate ? `${new Date(currentTour.startDate).toLocaleDateString('vi-VN')} - ${new Date(currentTour.endDate).toLocaleDateString('vi-VN')}` : "Chưa xác định ngày",
|
||||
membersCount: currentTour?.participants?.length || 0,
|
||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
{/* Top Navigation Bar */}
|
||||
<div className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-800 truncate px-4">
|
||||
{tourInfo.title}
|
||||
</h1>
|
||||
<div className="w-10" /> {/* Spacer */}
|
||||
</div>
|
||||
|
||||
{/* Tour Header Info */}
|
||||
<div className="relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end">
|
||||
<img
|
||||
src={tourInfo.coverImage}
|
||||
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="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-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full">
|
||||
<span className="text-white/60 mr-1">Lộ trình:</span>
|
||||
<span className="text-blue-300">Điểm xuất phát:</span>
|
||||
<span className="ml-1 text-white banner-location-text" title={startPoint?.name}>{startPoint?.name || '...'}</span>
|
||||
<span className="mx-2 text-white/30">-</span>
|
||||
<span className="text-green-300">Điểm kết thúc:</span>
|
||||
<span className="ml-1 text-white banner-location-text" title={endPoint?.name}>{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" />
|
||||
{tourInfo.date}
|
||||
</div>
|
||||
<div className="flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10">
|
||||
<Users className="w-4 h-4 mr-1.5" />
|
||||
{tourInfo.membersCount} thành viên
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Member Avatars Stack */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<div className="flex -space-x-3">
|
||||
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => (
|
||||
<div key={i} className="w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg">
|
||||
<img src={`https://i.pravatar.cc/100?u=${p.userId}`} alt="Avatar" />
|
||||
</div>
|
||||
))}
|
||||
{tourInfo.membersCount > 5 && (
|
||||
<div className="w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
|
||||
+{tourInfo.membersCount - 5}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentTour) return;
|
||||
if (canEdit) setIsAddMemberOpen(true);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
className={`p-2 rounded-full border backdrop-blur-sm transition-all ml-2 ${
|
||||
canEdit ? 'bg-white/10 hover:bg-white/20 border-white/20' : 'bg-white/5 border-white/10 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial Quick-View Widget or Quote */}
|
||||
<div className="max-w-2xl mx-auto -mt-10 px-4 relative z-10">
|
||||
<div className={`rounded-3xl p-6 shadow-2xl transition-all duration-500 ${hasFinanceAccess ? 'bg-indigo-600 text-white shadow-indigo-200' : 'bg-white text-gray-600 border border-gray-100'}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
{hasFinanceAccess ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-indigo-100 text-xs font-black uppercase tracking-widest mb-1">Tổng chi tiêu hiện tại</p>
|
||||
<h3 className="text-3xl font-black">{tourInfo.budget}</h3>
|
||||
</div>
|
||||
<div className="p-4 bg-white/10 rounded-2xl backdrop-blur-md"><Wallet className="w-8 h-8" /></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<Quote className="w-8 h-8 text-blue-500 opacity-30 flex-shrink-0" />
|
||||
<p className="italic text-lg font-medium leading-relaxed">"{randomQuote}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className={`w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}`} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Panels */}
|
||||
<div className="transition-opacity duration-300">
|
||||
{activeTab === 'plan' && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-gray-100 p-1 rounded-2xl flex gap-1">
|
||||
<button
|
||||
onClick={() => setViewMode('timeline')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" /> Danh sách
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
<MapIconLucide className="w-3.5 h-3.5" /> Bản đồ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'timeline' ? (
|
||||
<ItineraryTimeline onAddLocation={(legId) => {
|
||||
setTargetLegId(legId);
|
||||
setEditingLocation(null);
|
||||
setIsAddLocationOpen(true);
|
||||
}} onEditLocation={(loc) => {
|
||||
setEditingLocation(loc);
|
||||
setTargetLegId(loc.legId);
|
||||
setMapCenter([loc.latitude, loc.longitude]);
|
||||
setIsAddLocationOpen(true);
|
||||
}} />
|
||||
) : (
|
||||
<div className="h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative">
|
||||
<MapContainer
|
||||
center={initialViewState?.center || mapCenter}
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
{canEdit && <MapContextMenu onAction={handleMapAction} />}
|
||||
|
||||
{/* Tự động đóng khung Start và End khi dữ liệu thay đổi */}
|
||||
<MapTourBounds locations={allLocations} />
|
||||
|
||||
{/* Vẽ đường Polyline nối các điểm - Liên tục toàn bộ lộ trình xuyên suốt các chặng */}
|
||||
{allLocations.length > 1 && (
|
||||
<Polyline
|
||||
positions={allLocations.map(l => [l.latitude, l.longitude]) as any}
|
||||
color="#3b82f6"
|
||||
weight={3}
|
||||
dashArray="5, 10"
|
||||
smoothFactor={1.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{legs.flatMap(l => l.locations).map((loc: any) => {
|
||||
const isStart = startPoint?.id === loc.id;
|
||||
const isEnd = endPoint?.id === loc.id;
|
||||
// Sử dụng các icon tĩnh đã định nghĩa ở trên
|
||||
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||
|
||||
return (
|
||||
<Marker key={loc.id} position={[loc.latitude, loc.longitude]} icon={icon}>
|
||||
<Popup>
|
||||
<div className="font-bold">{loc.name}</div>
|
||||
<div className="text-xs text-gray-500">{loc.type}</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'expense' && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4">
|
||||
<ExpenseManager />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'photo' && (
|
||||
<div className="grid grid-cols-3 gap-1.5 animate-in fade-in">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white">
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<img
|
||||
src={`https://picsum.photos/seed/${i + 10}/400/400`}
|
||||
alt="Tour photo"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<div className="p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95">
|
||||
<Settings className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 font-medium">Tính năng quản lý thành viên đang được cập nhật...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button (Mobile) */}
|
||||
{canEdit && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
||||
{activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal */}
|
||||
{currentTour && (
|
||||
<AddMemberModal
|
||||
isOpen={isAddMemberOpen}
|
||||
onClose={() => setIsAddMemberOpen(false)}
|
||||
tourId={currentTour.id}
|
||||
participants={currentTour.participants || []}
|
||||
onRemoveMember={(userId) => removeMember(currentTour.id, userId)}
|
||||
onMemberAdded={() => fetchTour(currentTour.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Location Modal */}
|
||||
{currentTour && (
|
||||
<AddLocationModal
|
||||
isOpen={isAddLocationOpen}
|
||||
onClose={() => setIsAddLocationOpen(false)}
|
||||
initialLegId={targetLegId || undefined}
|
||||
editingLocation={editingLocation}
|
||||
tourId={currentTour.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor();
|
||||
onModuleInit(): Promise<void>;
|
||||
onModuleDestroy(): Promise<void>;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PrismaService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const client_1 = require("@prisma/client");
|
||||
const adapter_pg_1 = require("@prisma/adapter-pg");
|
||||
const pg_1 = require("pg");
|
||||
let PrismaService = class PrismaService extends client_1.PrismaClient {
|
||||
constructor() {
|
||||
console.log('--- [PRISMA CHECK] ---');
|
||||
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
|
||||
console.log('----------------------');
|
||||
const pool = new pg_1.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new adapter_pg_1.PrismaPg(pool);
|
||||
super({ adapter });
|
||||
}
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
};
|
||||
exports.PrismaService = PrismaService;
|
||||
exports.PrismaService = PrismaService = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [])
|
||||
], PrismaService);
|
||||
//# sourceMappingURL=prisma.service.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../../prisma/prisma.service.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA2E;AAC3E,2CAA8C;AAC9C,mDAA8C;AAC9C,2BAA0B;AAGnB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,qBAAY;IAC7C;QACE,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC;QACjH,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;QACtC,MAAM,IAAI,GAAG,IAAI,SAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,qBAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;IACrB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACtD,CAAA;AAZY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;;GACA,aAAa,CAYzB"}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
export declare class AdminGuard implements CanActivate {
|
||||
private prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AdminGuard = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||
let AdminGuard = class AdminGuard {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async canActivate(context) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user || !user.id) {
|
||||
throw new common_1.ForbiddenException('Yêu cầu xác thực không hợp lệ. Vui lòng đăng nhập.');
|
||||
}
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { isAdmin: true, isBlocked: true },
|
||||
});
|
||||
if (!dbUser || !dbUser.isAdmin || dbUser.isBlocked) {
|
||||
throw new common_1.ForbiddenException('Truy cập bị từ chối. Bạn không có quyền quản trị viên hệ thống.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
exports.AdminGuard = AdminGuard;
|
||||
exports.AdminGuard = AdminGuard = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], AdminGuard);
|
||||
//# sourceMappingURL=admin.guard.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"admin.guard.js","sourceRoot":"","sources":["../../../src/auth/admin.guard.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QAGpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACtB,MAAM,IAAI,2BAAkB,CAAC,oDAAoD,CAAC,CAAC;QACrF,CAAC;QAGD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC/C,KAAK,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE;YACtB,MAAM,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC3C,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACnD,MAAM,IAAI,2BAAkB,CAAC,iEAAiE,CAAC,CAAC;QAClG,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAzBY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,UAAU,CAyBtB"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
declare const JwtAuthGuard_base: import("@nestjs/passport").Type<import("@nestjs/passport").IAuthGuard>;
|
||||
export declare class JwtAuthGuard extends JwtAuthGuard_base {
|
||||
}
|
||||
export {};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.JwtAuthGuard = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const passport_1 = require("@nestjs/passport");
|
||||
let JwtAuthGuard = class JwtAuthGuard extends (0, passport_1.AuthGuard)('jwt') {
|
||||
};
|
||||
exports.JwtAuthGuard = JwtAuthGuard;
|
||||
exports.JwtAuthGuard = JwtAuthGuard = __decorate([
|
||||
(0, common_1.Injectable)()
|
||||
], JwtAuthGuard);
|
||||
//# sourceMappingURL=jwt-auth.guard.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"jwt-auth.guard.js","sourceRoot":"","sources":["../../../src/auth/jwt-auth.guard.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAC5C,+CAA6C;AAGtC,IAAM,YAAY,GAAlB,MAAM,YAAa,SAAQ,IAAA,oBAAS,EAAC,KAAK,CAAC;CAAG,CAAA;AAAxC,oCAAY;uBAAZ,YAAY;IADxB,IAAA,mBAAU,GAAE;GACA,YAAY,CAA4B"}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
declare const JwtStrategy_base: new (...args: any) => any;
|
||||
export declare class JwtStrategy extends JwtStrategy_base {
|
||||
private prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
validate(payload: any): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: Date;
|
||||
isAdmin: boolean;
|
||||
isBlocked: boolean;
|
||||
}>;
|
||||
}
|
||||
export {};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.JwtStrategy = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const passport_1 = require("@nestjs/passport");
|
||||
const passport_jwt_1 = require("passport-jwt");
|
||||
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||
let JwtStrategy = class JwtStrategy extends (0, passport_1.PassportStrategy)(passport_jwt_1.Strategy) {
|
||||
constructor(prisma) {
|
||||
super({
|
||||
jwtFromRequest: passport_jwt_1.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'super-secret',
|
||||
});
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async validate(payload) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
if (!user) {
|
||||
throw new common_1.UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
};
|
||||
exports.JwtStrategy = JwtStrategy;
|
||||
exports.JwtStrategy = JwtStrategy = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], JwtStrategy);
|
||||
//# sourceMappingURL=jwt.strategy.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"jwt.strategy.js","sourceRoot":"","sources":["../../../src/auth/jwt.strategy.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAmE;AACnE,+CAAoD;AACpD,+CAAoD;AACpD,gEAA4D;AAGrD,IAAM,WAAW,GAAjB,MAAM,WAAY,SAAQ,IAAA,2BAAgB,EAAC,uBAAQ,CAAC;IACzD,YAAoB,MAAqB;QACvC,KAAK,CAAC;YACJ,cAAc,EAAE,yBAAU,CAAC,2BAA2B,EAAE;YACxD,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,cAAc;SACtD,CAAC,CAAC;QALe,WAAM,GAAN,MAAM,CAAe;IAMzC,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,OAAY;QACzB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC;YAC7C,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,GAAG,EAAE;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,8BAAqB,CAAC,sDAAsD,CAAC,CAAC;QAC1F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AApBY,kCAAW;sBAAX,WAAW;IADvB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,WAAW,CAoBvB"}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
export declare class CompressCacheInterceptor implements NestInterceptor {
|
||||
private cacheManager;
|
||||
private readonly httpAdapterHost;
|
||||
constructor(cacheManager: Cache, httpAdapterHost: HttpAdapterHost);
|
||||
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.CompressCacheInterceptor = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const rxjs_1 = require("rxjs");
|
||||
const operators_1 = require("rxjs/operators");
|
||||
const cache_manager_1 = require("@nestjs/cache-manager");
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const util_1 = require("util");
|
||||
const core_1 = require("@nestjs/core");
|
||||
const gzip = (0, util_1.promisify)(zlib.gzip);
|
||||
const gunzip = (0, util_1.promisify)(zlib.gunzip);
|
||||
const COMPRESSION_THRESHOLD = 100;
|
||||
let CompressCacheInterceptor = class CompressCacheInterceptor {
|
||||
constructor(cacheManager, httpAdapterHost) {
|
||||
this.cacheManager = cacheManager;
|
||||
this.httpAdapterHost = httpAdapterHost;
|
||||
}
|
||||
async intercept(context, next) {
|
||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||
const request = context.getArgByIndex(0);
|
||||
const response = context.getArgByIndex(1);
|
||||
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||
return next.handle();
|
||||
}
|
||||
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||
let cachedData = await this.cacheManager.get(cacheKey);
|
||||
if (cachedData) {
|
||||
try {
|
||||
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||
const decompressed = await gunzip(cachedData);
|
||||
const jsonString = decompressed.toString('utf8');
|
||||
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||
}
|
||||
else {
|
||||
const jsonString = cachedData.toString();
|
||||
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||
return (0, rxjs_1.of)(JSON.parse(jsonString));
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||
await this.cacheManager.del(cacheKey);
|
||||
}
|
||||
}
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||
return next.handle().pipe((0, operators_1.tap)(async (data) => {
|
||||
if (!data)
|
||||
return;
|
||||
const jsonString = JSON.stringify(data);
|
||||
const ttl = 60000;
|
||||
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||
try {
|
||||
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
exports.CompressCacheInterceptor = CompressCacheInterceptor;
|
||||
exports.CompressCacheInterceptor = CompressCacheInterceptor = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__param(0, (0, common_1.Inject)(cache_manager_1.CACHE_MANAGER)),
|
||||
__metadata("design:paramtypes", [Object, core_1.HttpAdapterHost])
|
||||
], CompressCacheInterceptor);
|
||||
//# sourceMappingURL=compress-cache.interceptor.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"compress-cache.interceptor.js","sourceRoot":"","sources":["../../../src/common/compress-cache.interceptor.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,2CAAoG;AACpG,+BAAsC;AACtC,8CAAqC;AACrC,yDAAsD;AAEtD,2CAA6B;AAC7B,+BAAiC;AACjC,uCAA+C;AAG/C,MAAM,IAAI,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAClC,MAAM,MAAM,GAAG,IAAA,gBAAS,EAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAItC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAG3B,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACnC,YACiC,YAAmB,EACjC,eAAgC;QADlB,iBAAY,GAAZ,YAAY,CAAO;QACjC,oBAAe,GAAf,eAAe,CAAiB;IAChD,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,OAAyB,EAAE,IAAiB;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC;QACrD,MAAM,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;QAG1C,IAAI,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,KAAK,KAAK,EAAE,CAAC;YACpD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;QACvB,CAAC;QAED,MAAM,QAAQ,GAAG,WAAW,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACpD,IAAI,UAAU,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAS,QAAQ,CAAC,CAAC;QAE/D,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,CAAC;gBAEH,IAAI,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,UAAU,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC;oBAC7G,MAAM,YAAY,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;oBAC9C,MAAM,UAAU,GAAG,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;oBAEjD,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;oBAC/D,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;qBAAM,CAAC;oBAEN,MAAM,UAAU,GAAG,UAAU,CAAC,QAAQ,EAAE,CAAC;oBACzC,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,cAAc,EAAE,kBAAkB,CAAC,CAAC;oBACpE,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,oBAAoB,CAAC,CAAC;oBACjE,OAAO,IAAA,SAAE,EAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC;gBACpC,CAAC;YACH,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,OAAO,CAAC,KAAK,CAAC,8CAA8C,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;gBAE5E,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;QACH,CAAC;QAID,WAAW,CAAC,SAAS,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;QAEnD,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CACvB,IAAA,eAAG,EAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YACjB,IAAI,CAAC,IAAI;gBAAE,OAAO;YAElB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,KAAK,CAAC;YAElB,IAAI,UAAU,CAAC,MAAM,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,IAAI,CAAC;oBACH,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAC;oBAC/D,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;oBACvD,OAAO,CAAC,GAAG,CAAC,kCAAkC,QAAQ,KAAK,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM,SAAS,CAAC,CAAC;gBAEjH,CAAC;gBAAC,OAAO,CAAC,EAAE,CAAC;oBACX,OAAO,CAAC,KAAK,CAAC,mCAAmC,QAAQ,GAAG,EAAE,CAAC,CAAC,CAAC;oBAEjE,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;gBACzD,CAAC;YACH,CAAC;iBAAM,CAAC;gBAEN,MAAM,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC;YACzD,CAAC;QACH,CAAC,CAAC,CACH,CAAC;IACJ,CAAC;CACF,CAAA;AAxEY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,6BAAa,CAAC,CAAA;6CACY,sBAAe;GAHxC,wBAAwB,CAwEpC"}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
export declare class TourRoleGuard implements CanActivate {
|
||||
private prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.TourRoleGuard = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const prisma_service_1 = require("../../prisma/prisma.service");
|
||||
let TourRoleGuard = class TourRoleGuard {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async canActivate(context) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
const tourId = request.params.id || request.params.tourId;
|
||||
const path = request.url;
|
||||
if (!user || !tourId) {
|
||||
throw new common_1.ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: {
|
||||
tourId_userId: {
|
||||
tourId: tourId,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!participation) {
|
||||
throw new common_1.ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||
}
|
||||
request.tourParticipation = participation;
|
||||
const role = participation.role;
|
||||
const isPlanPath = path.includes('/plans');
|
||||
const isExpensePath = path.includes('/expenses');
|
||||
if ((role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||
(isPlanPath || isExpensePath)) {
|
||||
throw new common_1.ForbiddenException("Bạn không có quyền xem kế hoạch và chi phí của tour này.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
exports.TourRoleGuard = TourRoleGuard;
|
||||
exports.TourRoleGuard = TourRoleGuard = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], TourRoleGuard);
|
||||
//# sourceMappingURL=rbac.middleware.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../../../src/common/rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAA+F;AAC/F,gEAA4D;AAGrD,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,2BAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,2BAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,2BAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,sCAAa;wBAAb,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAEiB,8BAAa;GAD9B,aAAa,CA+CzB"}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
import 'reflect-metadata';
|
||||
import { OnGatewayConnection } from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ParticipantRole } from '@prisma/client';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
export declare const ROLES_KEY = "roles";
|
||||
export declare const Roles: (...roles: ParticipantRole[]) => import("@nestjs/common").CustomDecorator<string>;
|
||||
export declare class TourRoleGuard implements CanActivate {
|
||||
private reflector;
|
||||
private prisma;
|
||||
private cacheManager;
|
||||
constructor(reflector: Reflector, prisma: PrismaService, cacheManager: Cache);
|
||||
canActivate(context: ExecutionContext): Promise<boolean>;
|
||||
}
|
||||
export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
handleJoinTour(client: Socket, tourId: string): void;
|
||||
notifyNewComment(tourId: string, data: any): void;
|
||||
}
|
||||
Vendored
+1519
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": ".",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": ["**/*.prisma", "schema.sql"],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"start:dev": "nest start --watch",
|
||||
"db:generate": "dotenv -e ../.env -- prisma generate --schema=prisma/schema.prisma",
|
||||
"db:migrate": "dotenv -e ../.env -- prisma migrate dev --schema=prisma/schema.prisma",
|
||||
"db:seed": "dotenv -e ../.env -- tsx seed.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/pg": "^8.11.6",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"prisma": "^5.16.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.1.3",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.27",
|
||||
"@nestjs/platform-socket.io": "^11.1.27",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@prisma/adapter-pg": "^5.16.2",
|
||||
"@prisma/client": "^5.16.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"redis": "^6.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.35.1",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JoinRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExpenseCategory" AS ENUM ('ACCOMMODATION', 'FOOD', 'TRANSPORT', 'TICKET', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LocationStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LocationType" AS ENUM ('MOVE', 'VISIT', 'REST', 'EAT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrivacyLevel" AS ENUM ('PUBLIC', 'TOUR_ONLY', 'PRIVATE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"phone" TEXT,
|
||||
"address" TEXT,
|
||||
"avatar" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tour" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3),
|
||||
"endDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"totalCost" DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||
"createdById" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Tour_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "JoinRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"status" "JoinRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "JoinRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TourParticipant" (
|
||||
"tourId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "ParticipantRole" NOT NULL DEFAULT 'MEMBER',
|
||||
|
||||
CONSTRAINT "TourParticipant_pkey" PRIMARY KEY ("tourId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Leg" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"note" TEXT,
|
||||
|
||||
CONSTRAINT "Leg_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" TEXT NOT NULL,
|
||||
"legId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"address" TEXT,
|
||||
"latitude" DOUBLE PRECISION NOT NULL,
|
||||
"longitude" DOUBLE PRECISION NOT NULL,
|
||||
"plannedStart" TIMESTAMP(3),
|
||||
"plannedEnd" TIMESTAMP(3),
|
||||
"actualStart" TIMESTAMP(3),
|
||||
"actualEnd" TIMESTAMP(3),
|
||||
"status" "LocationStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"type" "LocationType" NOT NULL DEFAULT 'VISIT',
|
||||
|
||||
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Expense" (
|
||||
"id" TEXT NOT NULL,
|
||||
"leg_id" TEXT NOT NULL,
|
||||
"location_id" TEXT,
|
||||
"category" "ExpenseCategory" NOT NULL,
|
||||
"amount" DECIMAL(15,2) NOT NULL,
|
||||
"description" TEXT,
|
||||
"note" TEXT,
|
||||
"paid_by_id" TEXT,
|
||||
|
||||
CONSTRAINT "Expense_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Photo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tourId" TEXT NOT NULL,
|
||||
"locationId" TEXT,
|
||||
"uploaderId" TEXT NOT NULL,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"metadata" JSONB,
|
||||
"privacy" "PrivacyLevel" NOT NULL DEFAULT 'TOUR_ONLY',
|
||||
|
||||
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "JoinRequest_tourId_status_idx" ON "JoinRequest"("tourId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "JoinRequest_userId_idx" ON "JoinRequest"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JoinRequest" ADD CONSTRAINT "JoinRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TourParticipant" ADD CONSTRAINT "TourParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Leg" ADD CONSTRAINT "Leg_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_legId_fkey" FOREIGN KEY ("legId") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_leg_id_fkey" FOREIGN KEY ("leg_id") REFERENCES "Leg"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Expense" ADD CONSTRAINT "Expense_paid_by_id_fkey" FOREIGN KEY ("paid_by_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_tourId_fkey" FOREIGN KEY ("tourId") REFERENCES "Tour"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_uploaderId_fkey" FOREIGN KEY ("uploaderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Leg" ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "endDate" TIMESTAMP(3),
|
||||
ADD COLUMN "startDate" TIMESTAMP(3);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "childDiscount" INTEGER NOT NULL DEFAULT 30;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "description" TEXT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -6,6 +6,9 @@ import { Pool } from 'pg';
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
console.log('--- [PRISMA CHECK] ---');
|
||||
console.log('DATABASE_URL nhận được:', process.env.DATABASE_URL ? 'ĐÃ ĐỌC THÀNH CÔNG ✔️' : 'VẪN BỊ UNDEFINED ❌');
|
||||
console.log('----------------------');
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
super({ adapter });
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// --- Enums ---
|
||||
@@ -19,6 +21,12 @@ enum ParticipantRole {
|
||||
VIEWER_ONLY
|
||||
}
|
||||
|
||||
enum JoinRequestStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ExpenseCategory {
|
||||
ACCOMMODATION
|
||||
FOOD
|
||||
@@ -59,28 +67,55 @@ model User {
|
||||
isAdmin Boolean @default(false)
|
||||
isBlocked Boolean @default(false)
|
||||
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourParticipations TourParticipant[]
|
||||
uploadedPhotos Photo[]
|
||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||
createdTours Tour[] @relation("TourCreator")
|
||||
tourParticipations TourParticipant[]
|
||||
requestedJoinRequests JoinRequest[] @relation("JoinRequestUser")
|
||||
receivedJoinRequests JoinRequest[] @relation("JoinRequester")
|
||||
uploadedPhotos Photo[]
|
||||
paidExpenses Expense[] @relation("ExpensePaidBy")
|
||||
comments Comment[]
|
||||
|
||||
}
|
||||
|
||||
model Tour {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
totalCost Decimal @default(0) @db.Decimal(15, 2)
|
||||
|
||||
adultCount Int @default(1)
|
||||
childCount Int @default(0)
|
||||
childDiscount Int @default(30)
|
||||
|
||||
tags String[]
|
||||
createdById String
|
||||
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||
|
||||
participants TourParticipant[]
|
||||
joinRequests JoinRequest[]
|
||||
legs Leg[]
|
||||
photos Photo[]
|
||||
}
|
||||
|
||||
model JoinRequest {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
userId String
|
||||
requestedById String
|
||||
status JoinRequestStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
user User @relation("JoinRequestUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
requestedBy User @relation("JoinRequester", fields: [requestedById], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([tourId, status])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model TourParticipant {
|
||||
tourId String
|
||||
userId String
|
||||
@@ -97,6 +132,9 @@ model Leg {
|
||||
tourId String
|
||||
sequence Int
|
||||
note String? @db.Text
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
description String? @db.Text
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
locations Location[]
|
||||
@@ -122,6 +160,7 @@ model Location {
|
||||
leg Leg @relation(fields: [legId], references: [id], onDelete: Cascade)
|
||||
expenses Expense[]
|
||||
photos Photo[]
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Expense {
|
||||
@@ -141,15 +180,26 @@ model Expense {
|
||||
|
||||
model Photo {
|
||||
id String @id @default(uuid())
|
||||
tourId String
|
||||
tourId String?
|
||||
locationId String?
|
||||
uploaderId String
|
||||
imageUrl String
|
||||
imageUrl String?
|
||||
originalUrl String?
|
||||
capturedAt DateTime @default(now())
|
||||
metadata Json?
|
||||
privacy PrivacyLevel @default(TOUR_ONLY)
|
||||
|
||||
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
|
||||
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid())
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
locationId String
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const envPath = path.resolve(process.cwd(), '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const adapter = new PrismaPg(pool);
|
||||
@@ -90,6 +94,25 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log('--- Đang tạo bình luận mẫu... ---');
|
||||
const dinhDocLap = await prisma.location.findFirst({ where: { name: 'Dinh Độc Lập' } });
|
||||
if (dinhDocLap) {
|
||||
await prisma.comment.createMany({
|
||||
data: [
|
||||
{
|
||||
content: 'Chỗ này rất đẹp, giàu giá trị lịch sử!',
|
||||
locationId: dinhDocLap.id,
|
||||
userId: owner.id,
|
||||
},
|
||||
{
|
||||
content: 'Nên đi vào buổi sáng cho mát mẻ mọi người nhé.',
|
||||
locationId: dinhDocLap.id,
|
||||
userId: photoMember.id,
|
||||
}
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
console.log('--- Seed dữ liệu hoàn tất! ---');
|
||||
console.log(`Email đăng nhập Owner: ${owner.email}`);
|
||||
console.log(`Email đăng nhập Photo Only: ${photoMember.email}`);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Inject } from '@nestjs/common';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
|
||||
// Promisify các hàm nén/giải nén
|
||||
const gzip = promisify(zlib.gzip);
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
// Ngưỡng nén: Chỉ nén nếu chuỗi JSON lớn hơn ngưỡng này (bytes)
|
||||
// Nén dữ liệu quá nhỏ có thể làm tăng kích thước do overhead của header nén
|
||||
const COMPRESSION_THRESHOLD = 100;
|
||||
|
||||
@Injectable()
|
||||
export class CompressCacheInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private readonly httpAdapterHost: HttpAdapterHost, // Để truy cập request/response
|
||||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
||||
const request = context.getArgByIndex(0);
|
||||
const response = context.getArgByIndex(1);
|
||||
|
||||
// Chỉ áp dụng cho các request GET
|
||||
if (httpAdapter.getRequestMethod(request) !== 'GET') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cacheKey = httpAdapter.getRequestUrl(request);
|
||||
let cachedData = await this.cacheManager.get<Buffer>(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
try {
|
||||
// Kiểm tra xem dữ liệu có phải là Buffer và có Gzip header (0x1f 0x8b) không
|
||||
if (Buffer.isBuffer(cachedData) && cachedData.length > 2 && cachedData[0] === 0x1f && cachedData[1] === 0x8b) {
|
||||
const decompressed = await gunzip(cachedData);
|
||||
const jsonString = decompressed.toString('utf8');
|
||||
|
||||
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Compressed)');
|
||||
return of(JSON.parse(jsonString));
|
||||
} else {
|
||||
// Dữ liệu không nén (lưu dưới dạng string hoặc buffer thường)
|
||||
const jsonString = cachedData.toString();
|
||||
httpAdapter.setHeader(response, 'Content-Type', 'application/json');
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'HIT (Uncompressed)');
|
||||
return of(JSON.parse(jsonString));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Cache] Lỗi giải nén dữ liệu cache cho key ${cacheKey}:`, e);
|
||||
// Nếu giải nén lỗi, coi như cache miss và xóa cache bị lỗi
|
||||
await this.cacheManager.del(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss hoặc giải nén lỗi, tiếp tục xử lý request
|
||||
// Đặt header MISS ngay lập tức trước khi chạy logic Controller
|
||||
httpAdapter.setHeader(response, 'X-Cache', 'MISS');
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async (data) => { // Sử dụng tap để thực hiện side effect (lưu cache) mà không thay đổi dữ liệu gốc
|
||||
if (!data) return;
|
||||
|
||||
const jsonString = JSON.stringify(data);
|
||||
const ttl = 60000; // TTL mặc định 1 phút (có thể cấu hình từ CACHE_TTL.DEFAULT)
|
||||
|
||||
if (jsonString.length > COMPRESSION_THRESHOLD) {
|
||||
try {
|
||||
const compressed = await gzip(Buffer.from(jsonString, 'utf8'));
|
||||
await this.cacheManager.set(cacheKey, compressed, ttl);
|
||||
console.log(`[Cache] 📦 Đã nén dữ liệu cho: ${cacheKey} (${jsonString.length} -> ${compressed.length} bytes)`);
|
||||
// Không setHeader ở đây vì response có thể đã gửi xong
|
||||
} catch (e) {
|
||||
console.error(`[Cache] Lỗi nén dữ liệu cho key ${cacheKey}:`, e);
|
||||
// Nếu nén lỗi, lưu dữ liệu không nén làm fallback
|
||||
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||
}
|
||||
} else {
|
||||
// Dữ liệu quá nhỏ, lưu không nén
|
||||
await this.cacheManager.set(cacheKey, jsonString, ttl);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TourRoleGuard implements CanActivate {
|
||||
@@ -7,9 +7,8 @@ export class TourRoleGuard implements CanActivate {
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user; // Giả sử đã qua AuthGuard (Passport/JWT)
|
||||
const user = request.user;
|
||||
|
||||
// UUID không cần parseInt
|
||||
const tourId = request.params.id || request.params.tourId;
|
||||
const path = request.url;
|
||||
|
||||
@@ -17,11 +16,11 @@ export class TourRoleGuard implements CanActivate {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
|
||||
/**
|
||||
* TỐI ƯU: Chỉ truy vấn Database 1 lần duy nhất để lấy thông tin thành viên.
|
||||
* Chúng ta lưu kết quả vào request object để các interceptor hoặc controller
|
||||
* sau này có thể dùng lại mà không cần query lại.
|
||||
*/
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: {
|
||||
tourId_userId: {
|
||||
@@ -35,14 +34,12 @@ export class TourRoleGuard implements CanActivate {
|
||||
throw new ForbiddenException("Bạn không phải là thành viên của tour này.");
|
||||
}
|
||||
|
||||
// Gắn thông tin vào request để sử dụng ở tầng Controller
|
||||
request.tourParticipation = participation;
|
||||
|
||||
const role = participation.role;
|
||||
const isPlanPath = path.includes('/plans');
|
||||
const isExpensePath = path.includes('/expenses');
|
||||
|
||||
// Theo định nghĩa mới: MEMBER_NO_FINANCE và VIEWER_ONLY bị hạn chế
|
||||
if (
|
||||
(role === 'MEMBER_NO_FINANCE' || role === 'VIEWER_ONLY') &&
|
||||
(isPlanPath || isExpensePath)
|
||||
+1401
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node", // Đảm bảo module resolution là 'node'
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true, // Cho phép import các file .json
|
||||
"esModuleInterop": true, // Cho phép cú pháp import/export ES Modules với CommonJS
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
@@ -0,0 +1,208 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// Types for notification modal
|
||||
export interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
|
||||
// Icon components for different types
|
||||
const Icons = {
|
||||
info: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 16v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 8h.01" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
success: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M8 12l2.5 2.5L15.5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
warning: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
error: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Default props
|
||||
const defaultProps: Partial<NotificationModalProps> = {
|
||||
title: 'Thông báo',
|
||||
type: 'info',
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Hủy',
|
||||
};
|
||||
|
||||
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
title = defaultProps.title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
type = defaultProps.type,
|
||||
confirmButtonText = defaultProps.confirmButtonText,
|
||||
cancelButtonText = defaultProps.cancelButtonText,
|
||||
}) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Get colors based on type
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||
case 'warning':
|
||||
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||
case 'error':
|
||||
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||
default:
|
||||
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getTypeStyles();
|
||||
|
||||
// Animation classes based on state
|
||||
const getAnimationClass = () => {
|
||||
if (!isOpen) return 'opacity-0 translate-y-4';
|
||||
if (isAnimating && onCancel) return 'animate-fade-out';
|
||||
return 'animate-fade-in';
|
||||
};
|
||||
|
||||
// Handle confirm click
|
||||
const handleConfirm = () => {
|
||||
setIsAnimating(true);
|
||||
onConfirm?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
|
||||
// Handle cancel click
|
||||
const handleCancel = () => {
|
||||
setIsAnimating(true);
|
||||
onCancel?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4">
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-message"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`p-6 border-b ${styles.border}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{type === 'success' && <Icons.success className="w-5 h-5 text-green-600" />}
|
||||
{type === 'warning' && <Icons.warning className="w-5 h-5 text-yellow-600" />}
|
||||
{type === 'error' && <Icons.error className="w-5 h-5 text-red-600" />}
|
||||
{type === 'info' && <Icons.info className="w-5 h-5 text-blue-600" />}
|
||||
<h2 id="modal-title" className={`text-xl font-semibold ${styles.text}`}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<p id="modal-message" className="text-gray-700 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`}>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
{cancelButtonText}
|
||||
</button>
|
||||
)}
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`px-4 py-2 text-white rounded-md font-medium transition-colors ${
|
||||
type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for easy usage without props management
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const openModal = (
|
||||
title: string,
|
||||
message: string,
|
||||
type: 'info' | 'success' | 'warning' | 'error' = 'info',
|
||||
onConfirm?: () => void,
|
||||
onCancel?: () => void,
|
||||
) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
|
||||
// Auto-close after 5 seconds if no confirm action
|
||||
const timer = setTimeout(() => {
|
||||
if (onCancel) {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||
};
|
||||
|
||||
return {
|
||||
modalState,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
|
||||
export default NotificationModal;
|
||||
Vendored
+12
@@ -12,8 +12,20 @@ interface AddMemberModalProps {
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
joinRequests?: Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
status: string;
|
||||
requestedById: string;
|
||||
}>;
|
||||
onRemoveMember?: (userId: string) => Promise<void>;
|
||||
onMemberAdded?: () => void;
|
||||
userRole?: string;
|
||||
}
|
||||
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||
export {};
|
||||
|
||||
Vendored
+44
-11
@@ -1,7 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, UserPlus, Loader2, Shield, Trash2 } from 'lucide-react';
|
||||
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onRemoveMember, onMemberAdded }) => {
|
||||
import { X, UserPlus, Loader2, Shield, Trash2, Clock } from 'lucide-react';
|
||||
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -12,8 +12,11 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [confirmTarget, setConfirmTarget] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => r.userId)), [joinRequests]);
|
||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setFetchError('');
|
||||
@@ -68,6 +71,32 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
|
||||
setConfirmTarget(null);
|
||||
}
|
||||
};
|
||||
const handleRequestAction = async (reqId, action, userName) => {
|
||||
if (!onMemberAdded)
|
||||
return;
|
||||
setActionLoading(reqId);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const endpoint = action === 'accept'
|
||||
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||
}
|
||||
await onMemberAdded();
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message || 'Thao tác thất bại');
|
||||
}
|
||||
finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
const handleAdd = async () => {
|
||||
if (!selectedUser)
|
||||
return;
|
||||
@@ -75,23 +104,27 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
|
||||
setSubmitError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||
const body = canCreateDirectly
|
||||
? { userId: selectedUser, role }
|
||||
: { userId: selectedUser };
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ userId: selectedUser, role }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || 'Thêm thành viên thất bại');
|
||||
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||
}
|
||||
onMemberAdded?.();
|
||||
await onMemberAdded?.();
|
||||
onClose();
|
||||
}
|
||||
catch (err) {
|
||||
setSubmitError(err.message || 'Thêm thành viên thất bại');
|
||||
setSubmitError(err.message || 'Thao tác thất bại');
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false);
|
||||
@@ -99,7 +132,7 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
|
||||
};
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " Th\u00EAm th\u00E0nh vi\u00EAn"] }), _jsx("p", { className: "text-xs text-gray-500", children: "Ch\u1ECDn ng\u01B0\u1EDDi d\u00F9ng v\u00E0 ph\u00E2n quy\u1EC1n cho tour n\u00E0y." })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2000] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: onClose }), _jsxs("div", { className: "relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]", children: [_jsxs("div", { className: "p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50", children: [_jsxs("div", { children: [_jsxs("h2", { className: "text-lg font-bold text-gray-900 flex items-center gap-2", children: [_jsx(UserPlus, { className: "w-5 h-5 text-blue-600" }), " ", canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'] }), _jsx("p", { className: "text-xs text-gray-500", children: canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.' })] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-200 rounded-full transition-colors", children: _jsx(X, { className: "w-5 h-5 text-gray-400" }) })] }), _jsxs("div", { className: "p-5 space-y-4", children: [_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["Th\u00E0nh vi\u00EAn c\u1EE7a tour (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [participants.map((p) => {
|
||||
const rawToken = localStorage.getItem('token');
|
||||
let currentUserId = null;
|
||||
try {
|
||||
@@ -113,9 +146,9 @@ export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onR
|
||||
const isOwner = p.role === 'OWNER';
|
||||
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||
return (_jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden", children: p.user?.name?.charAt(0) || '?' }), canRemove && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white", "aria-label": "Remove item", children: _jsx(Trash2, { size: 10 }) }))] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: p.user?.name || p.userId })] }, p.userId));
|
||||
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" }), _jsx("input", { className: "w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm", placeholder: "T\u00ECm theo t\u00EAn ho\u1EB7c email...", value: query, onChange: (e) => setQuery(e.target.value), onBlur: fetchUsers })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] }), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
|
||||
}), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), joinRequests.length > 0 && (_jsxs("div", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3 text-amber-500" }), " \u0110ang ch\u1EDD ph\u00EA duy\u1EC7t (", joinRequests.length, ")"] }), _jsx("div", { className: "flex flex-wrap gap-3", children: joinRequests.map((req) => (_jsxs("div", { className: "flex flex-col items-center gap-1 relative", children: [_jsx("div", { className: "w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'accept', req.user?.name || req.userId), className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: actionLoading === req.id, onClick: () => handleRequestAction(req.id, 'reject', req.user?.name || req.userId), className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] }), _jsx("span", { className: "text-[10px] font-semibold text-gray-700 max-w-[72px] truncate", children: req.user?.name || req.userId }), _jsx("span", { className: "text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200", children: "PENDING" })] }, req.id))) })] })), canCreateDirectly && (_jsxs("div", { className: "space-y-1.5", children: [_jsx("label", { className: "text-xs font-bold text-gray-700 ml-1", children: "Ph\u00E2n quy\u1EC1n" }), _jsxs("select", { className: "w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm", value: role, onChange: (e) => setRole(e.target.value), children: [_jsx("option", { value: "OWNER", children: "OWNER" }), _jsx("option", { value: "MANAGER", children: "MANAGER" }), _jsx("option", { value: "MEMBER", children: "MEMBER" }), _jsx("option", { value: "MEMBER_NO_FINANCE", children: "MEMBER_NO_FINANCE" }), _jsx("option", { value: "VIEWER_ONLY", children: "VIEWER_ONLY" })] })] })), _jsxs("div", { className: "space-y-2", children: [fetchError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: fetchError })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), loading ? (_jsx("div", { className: "flex justify-center py-10", children: _jsx(Loader2, { className: "w-8 h-8 animate-spin text-blue-600" }) })) : (_jsxs("div", { className: "space-y-2 max-h-[40vh] overflow-y-auto pr-1", children: [visibleUsers.map((u) => {
|
||||
const isSelected = selectedUser === u.id;
|
||||
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `• ${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
|
||||
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang thêm...' : 'Thêm vào tour' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { onClick: confirmRemove, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: "X\u00F3a" })] })] })] }))] }));
|
||||
return (_jsxs("button", { onClick: () => setSelectedUser(u.id), disabled: requestUserIds.has(u.id), className: `w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`, children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold", children: u.name?.charAt(0) || '?' }), _jsxs("div", { className: "flex-1 text-left", children: [_jsx("div", { className: "text-sm font-bold text-gray-900", children: u.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-[11px] text-gray-500", children: u.email }), _jsxs("div", { className: "text-[11px] text-gray-400", children: [u.phone || '', " ", u.address ? `• ${u.address}` : ''] })] }), _jsxs("div", { className: "flex items-center gap-1 text-[10px] font-bold text-gray-500", children: [u.isAdmin ? (_jsx("span", { className: "px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100", children: "ADMIN" })) : (_jsx("span", { className: "px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100", children: "USER" })), isSelected && _jsx(Shield, { className: "w-3 h-3 text-blue-600" })] })] }, u.id));
|
||||
}), !loading && visibleUsers.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng t\u00ECm th\u1EA5y ng\u01B0\u1EDDi d\u00F9ng ph\u00F9 h\u1EE3p" }))] }))] })] }), _jsxs("div", { className: "p-5 border-t border-gray-100 flex justify-end gap-2", children: [_jsx("button", { onClick: onClose, className: "px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { disabled: !selectedUser || submitting, onClick: handleAdd, className: "px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all", children: submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời' })] })] }), isConfirmOpen && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: () => setIsConfirmOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: "X\u00E1c nh\u1EADn x\u00F3a th\u00E0nh vi\u00EAn" }), _jsxs("p", { className: "mt-2 text-sm text-gray-600", children: ["B\u1EA1n c\u00F3 ch\u1EAFc mu\u1ED1n x\u00F3a ", _jsx("span", { className: "font-semibold text-gray-800", children: confirmTarget?.name }), " kh\u1ECFi tour n\u00E0y?"] }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsConfirmOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: "H\u1EE7y" }), _jsx("button", { onClick: confirmRemove, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: "X\u00F3a" })] })] })] }))] }));
|
||||
};
|
||||
//# sourceMappingURL=AddMemberModal.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
export declare const ConfirmModal: React.FC<ConfirmModalProps>;
|
||||
export {};
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export const ConfirmModal = ({ isOpen, title = 'Xác nhận', message, confirmText = 'Xác nhận', cancelText = 'Hủy', onConfirm, onCancel, }) => {
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[2200] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/70 backdrop-blur-sm", onClick: onCancel }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsx("h3", { className: "text-base font-bold text-gray-900", children: title }), _jsx("p", { className: "mt-2 text-sm text-gray-600", children: message }), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: onCancel, className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors", children: cancelText }), _jsx("button", { onClick: onConfirm, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors", children: confirmText })] })] })] }));
|
||||
};
|
||||
//# sourceMappingURL=ConfirmModal.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ConfirmModal.js","sourceRoot":"","sources":["../ConfirmModal.tsx"],"names":[],"mappings":";AAaA,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACxD,MAAM,EACN,KAAK,GAAG,UAAU,EAClB,OAAO,EACP,WAAW,GAAG,UAAU,EACxB,UAAU,GAAG,KAAK,EAClB,SAAS,EACT,QAAQ,GACT,EAAE,EAAE;IACH,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,OAAO,CACL,eAAK,SAAS,EAAC,6DAA6D,aAC1E,cAAK,SAAS,EAAC,kDAAkD,EAAC,OAAO,EAAE,QAAQ,GAAI,EACvF,eAAK,SAAS,EAAC,8DAA8D,aAC3E,aAAI,SAAS,EAAC,mCAAmC,YAAE,KAAK,GAAM,EAC9D,YAAG,SAAS,EAAC,4BAA4B,YAAE,OAAO,GAAK,EACvD,eAAK,SAAS,EAAC,6BAA6B,aAC1C,iBAAQ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAC,0FAA0F,YAC5H,UAAU,GACJ,EACT,iBAAQ,OAAO,EAAE,SAAS,EAAE,SAAS,EAAC,iGAAiG,YACpI,WAAW,GACL,IACL,IACF,IACF,CACP,CAAC;AACJ,CAAC,CAAC"}
|
||||
Vendored
+12
-11
@@ -70,23 +70,24 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }) => {
|
||||
}, []);
|
||||
return (_jsxs("div", { className: "h-screen w-full relative", children: [_jsx("button", { onClick: onBack, className: "absolute top-6 left-6 z-[1000] bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all", children: _jsx(X, { className: "w-6 h-6 text-gray-800" }) }), onLogout && (_jsxs("button", { onClick: onLogout, className: "absolute top-6 right-6 z-[1000] bg-white px-4 py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700", children: [_jsx(LogOut, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "\u0110\u0103ng xu\u1EA5t" })] })), user?.isAdmin && (_jsxs("button", { onClick: () => setIsAdminModalOpen(true), className: "absolute top-6 right-40 z-[1000] bg-blue-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Settings, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "Qu\u1EA3n l\u00FD h\u1EC7 th\u1ED1ng" })] })), user && (_jsxs("button", { onClick: () => setIsCreateModalOpen(true), className: "absolute top-6 right-80 z-[1000] bg-green-600 px-4 py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold", children: [_jsx(Navigation, { className: "w-5 h-5" }), _jsx("span", { className: "hidden sm:inline", children: "T\u1EA1o Tour m\u1EDBi" })] })), _jsx("div", { className: "absolute top-6 left-20 z-[1000] bg-white/90 backdrop-blur-md px-6 py-3 rounded-2xl shadow-xl border border-white/20 hidden sm:block", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Navigation, { className: "w-4 h-4 text-blue-600" }), _jsx("span", { className: "font-bold text-gray-800", children: "\u0110ang kh\u00E1m ph\u00E1 khu v\u1EF1c c\u1EE7a b\u1EA1n" })] }) }), _jsxs(MapContainer, { center: userPos, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: '\u00A9 OpenStreetMap contributors' }), _jsx(MapTracker, {}), _jsx(RecenterMap, { position: userPos }), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: publicTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
if (!startLoc)
|
||||
return null;
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: [startLoc.latitude, startLoc.longitude], eventHandlers: {
|
||||
const markerPos = startLoc
|
||||
? [startLoc.latitude, startLoc.longitude]
|
||||
: userPos;
|
||||
return (_jsx(React.Fragment, { children: _jsx(Marker, { position: markerPos, eventHandlers: {
|
||||
click: () => onViewTour(tour.id)
|
||||
}, icon: L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<div class="relative group">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||
<div class="relative group">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
}) }) }, tour.id));
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+59
-40
@@ -1,7 +1,9 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
const TimeVariance = ({ planned, actual }) => {
|
||||
if (!actual)
|
||||
return null;
|
||||
@@ -26,6 +28,7 @@ const formatTravelTime = (minutes) => {
|
||||
};
|
||||
export const ItineraryTimeline = ({ onAddLocation, onEditLocation }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||
const [confirmState, setConfirmState] = useState({ open: false });
|
||||
const toggleComplete = async (locationId) => {
|
||||
console.log("Toggle status for location:", locationId);
|
||||
};
|
||||
@@ -49,48 +52,64 @@ export const ItineraryTimeline = ({ onAddLocation, onEditLocation }) => {
|
||||
}
|
||||
};
|
||||
const handleDeleteLeg = async (legId) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa chặng',
|
||||
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleDeleteLocation = async (id) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa địa điểm',
|
||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
return (_jsx("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: _jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||
if (loc.plannedStart && loc.plannedEnd) {
|
||||
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: `absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||
const distanceToNext = nextLocation
|
||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||
: null;
|
||||
const averageSpeed = 35;
|
||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1 font-bold", children: [_jsx(Zap, { className: "w-3 h-3" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }), locationExpense.description && (_jsxs("div", { className: "text-[10px] text-gray-600", children: ["D\u1ECBch v\u1EE5: ", locationExpense.description] })), locationExpense.note && (_jsx("div", { className: "text-[10px] text-gray-500 italic", children: locationExpense.note })), locationExpense.paidBy && (_jsxs("div", { className: "text-[10px] font-semibold text-indigo-700", children: ["\u0110\u00E3 thanh to\u00E1n: ", locationExpense.paidBy.name] }))] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
}) })] }, leg.id));
|
||||
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }) }));
|
||||
return (_jsxs("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: [_jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||
if (loc.plannedStart && loc.plannedEnd) {
|
||||
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
return (_jsxs("div", { className: "relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300", children: [_jsxs("div", { className: "sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2", children: [_jsxs("div", { className: "font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm", children: leg.sequence }), leg.note || `Chi tiết Chặng ${leg.sequence}`] }), prevLegLastLoc && (_jsxs("div", { className: "flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10", children: [_jsx(Navigation, { className: "w-2.5 h-2.5 rotate-90" }), " Ti\u1EBFp n\u1ED1i t\u1EEB ", prevLegLastLoc.name] }))] }), _jsxs("div", { className: "flex items-center gap-2 ml-4", children: [['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: () => onAddLocation?.(leg.id), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", title: "Th\u00EAm \u0111\u1ECBa \u0111i\u1EC3m v\u00E0o ch\u1EB7ng n\u00E0y", children: _jsx(Plus, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleEditLeg(leg), className: "p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all", children: _jsx(Edit2, { className: "w-4 h-4" }) }), _jsx("button", { onClick: () => handleDeleteLeg(leg.id), className: "p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all", children: _jsx(Trash2, { className: "w-4 h-4" }) })] })), leg.totalDistance !== undefined && (_jsxs("div", { className: "hidden sm:flex items-center gap-2", children: [_jsxs("div", { className: "text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100", children: [leg.totalDistance, " km"] }), _jsxs("div", { className: "text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "~ ", formatTravelTime(Math.round((leg.totalDistance / 35) * 60))] })] })), totalDwellMinutes > 0 && (_jsxs("div", { className: "hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1", children: [_jsx(Clock, { className: "w-3 h-3" }), "D\u1EEBng: ", formatTravelTime(totalDwellMinutes)] }))] }), ['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (_jsxs("button", { onClick: () => optimizeRouting(leg.id), className: "ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all", children: [_jsx(Zap, { className: "w-3 h-3" }), "T\u1ED1i \u01B0u"] }))] }), _jsx("div", { className: `absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0` }), _jsx("div", { className: "ml-2", children: leg.locations.map((location, idx) => {
|
||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||
const distanceToNext = nextLocation
|
||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||
: null;
|
||||
const averageSpeed = 35;
|
||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
const locationExpense = leg.expenses?.find((e) => e.locationId === location.id);
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("div", { className: `flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("span", { className: "inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), location.note && (_jsx("div", { className: "mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic", children: location.note })), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] })), locationExpense && (_jsxs("div", { className: "mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1 font-bold", children: [_jsx(Zap, { className: "w-3 h-3" }), _jsxs("span", { children: ["Chi ph\u00ED: ", Number(locationExpense.amount).toLocaleString(), "\u0111 (", locationExpense.category, ")"] })] }), locationExpense.description && (_jsxs("div", { className: "text-[10px] text-gray-600", children: ["D\u1ECBch v\u1EE5: ", locationExpense.description] })), locationExpense.note && (_jsx("div", { className: "text-[10px] text-gray-500 italic", children: locationExpense.note })), locationExpense.paidBy && (_jsxs("div", { className: "text-[10px] font-semibold text-indigo-700", children: ["\u0110\u00E3 thanh to\u00E1n: ", locationExpense.paidBy.name] }))] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("span", { className: "text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
}) })] }, leg.id));
|
||||
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("button", { onClick: handleDeclareLegs, className: "w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(List, { className: "w-5 h-5" }), legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"] }), _jsxs("button", { onClick: handleAddLeg, className: "w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) })] }));
|
||||
};
|
||||
//# sourceMappingURL=ItineraryTimeline.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+124
-5
@@ -5,11 +5,13 @@ import { ExpenseManager } from './ExpenseManager.js';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { AddLocationModal } from './AddLocationModal.js';
|
||||
import { AddMemberModal } from './AddMemberModal.js';
|
||||
import { ConfirmModal } from './ConfirmModal.js';
|
||||
import { NotificationModal, useNotificationModal } from './components/NotificationModal.js';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = _MarkerClusterGroup.default || _MarkerClusterGroup;
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag } from 'lucide-react';
|
||||
import { Map as MapIcon, Wallet, Image as ImageIcon, Calendar, Users, ChevronLeft, Settings, Quote, Plus, List, Map as MapIconLucide, MapPin, Flag, Clock, Check, X } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
@@ -97,6 +99,13 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||
const [targetLegId, setTargetLegId] = useState(null);
|
||||
const [editingLocation, setEditingLocation] = useState(null);
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
const [isMemberDetailOpen, setIsMemberDetailOpen] = useState(false);
|
||||
const [joinRequests, setJoinRequests] = useState([]);
|
||||
const [joinRequestActionId, setJoinRequestActionId] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState({ open: false });
|
||||
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember, fetchJoinRequests, acceptJoinRequest, rejectJoinRequest } = useTourStore();
|
||||
const notificationModal = useNotificationModal();
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
@@ -109,8 +118,12 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember } = useTourStore();
|
||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
useEffect(() => {
|
||||
if (currentTour && ['OWNER', 'MANAGER'].includes(userRole || '')) {
|
||||
fetchJoinRequests(currentTour.id).then(setJoinRequests).catch(() => setJoinRequests([]));
|
||||
}
|
||||
}, [currentTour, userRole]);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||
useEffect(() => {
|
||||
@@ -238,7 +251,56 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
|
||||
coverImage: currentTour?.photos?.[0]?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
|
||||
};
|
||||
return (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("div", { className: "mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex -space-x-3", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg", children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, i))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||
return (_jsxs("div", { className: "min-h-screen bg-gray-50 pb-20", children: [_jsxs("div", { className: "sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-gray-100 px-4 py-3 flex items-center justify-between", children: [_jsx("button", { onClick: onBack, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(ChevronLeft, { className: "w-6 h-6 text-gray-600" }) }), _jsx("h1", { className: "text-lg font-bold text-gray-800 truncate px-4", children: tourInfo.title }), _jsx("div", { className: "w-10" }), " "] }), _jsxs("div", { className: "relative min-h-[480px] w-full bg-blue-900 flex flex-col justify-end", children: [_jsx("img", { src: tourInfo.coverImage, className: "absolute inset-0 w-full h-full object-cover opacity-60", alt: "Tour Cover" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" }), _jsx("div", { className: "relative z-10 p-6 text-white pt-28 pb-20", children: _jsxs("div", { className: "max-w-2xl mx-auto space-y-4", children: [_jsx("h2", { className: "text-3xl font-black tracking-tight drop-shadow-md", children: tourInfo.title }), _jsxs("div", { className: "mt-3 text-[11px] sm:text-sm font-bold text-white bg-black/40 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 inline-flex items-center max-w-full", children: [_jsx("span", { className: "text-white/60 mr-1", children: "L\u1ED9 tr\u00ECnh:" }), _jsx("span", { className: "text-blue-300", children: "\u0110i\u1EC3m xu\u1EA5t ph\u00E1t:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: startPoint?.name, children: startPoint?.name || '...' }), _jsx("span", { className: "mx-2 text-white/30", children: "-" }), _jsx("span", { className: "text-green-300", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc:" }), _jsx("span", { className: "ml-1 text-white banner-location-text", title: endPoint?.name, children: endPoint?.name || '...' })] }), _jsxs("div", { className: "flex flex-wrap gap-4 text-sm font-medium opacity-90", children: [_jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Calendar, { className: "w-4 h-4 mr-1.5" }), tourInfo.date] }), _jsxs("div", { className: "flex items-center bg-black/20 backdrop-blur-sm px-3 py-1 rounded-full border border-white/10", children: [_jsx(Users, { className: "w-4 h-4 mr-1.5" }), tourInfo.membersCount, " th\u00E0nh vi\u00EAn"] })] }), _jsxs("div", { className: "flex items-center gap-2 mt-4", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [currentTour?.participants?.slice(0, 5).map((p, i) => (_jsx("button", { onClick: () => {
|
||||
setSelectedMember(p);
|
||||
setIsMemberDetailOpen(true);
|
||||
}, className: "w-10 h-10 rounded-full border-2 border-white bg-blue-100 flex items-center justify-center text-blue-600 font-bold overflow-hidden shadow-lg hover:scale-110 transition-transform", title: p.user?.name || p.userId, children: _jsx("img", { src: `https://i.pravatar.cc/100?u=${p.userId}`, alt: "Avatar" }) }, p.userId || i))), joinRequests.slice(0, 3).map((req) => (_jsxs("div", { className: "relative group", children: [_jsx("div", { className: "w-10 h-10 rounded-full border-2 border-amber-400 bg-amber-50 flex items-center justify-center text-amber-700 font-bold overflow-hidden shadow-lg", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { className: "absolute -top-1 -right-1 flex", children: [_jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50", "aria-label": "Accept", children: "+" }), _jsx("button", { type: "button", disabled: joinRequestActionId === req.id, onClick: async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Từ chối yêu cầu',
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1", "aria-label": "Reject", children: "x" })] })] }, req.id))), tourInfo.membersCount > 5 && (_jsxs("div", { className: "w-10 h-10 rounded-full border-2 border-white bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white shadow-lg", children: ["+", tourInfo.membersCount - 5] }))] }), _jsx("button", { onClick: () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
if (canEdit)
|
||||
@@ -259,11 +321,68 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
const isEnd = endPoint?.id === loc.id;
|
||||
const icon = isStart ? START_ICON : isEnd ? END_ICON : VISIT_ICON;
|
||||
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
||||
}) })] }), _jsx("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", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsx(Settings, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng qu\u1EA3n l\u00FD th\u00E0nh vi\u00EAn \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||
}) })] }), _jsx("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", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "p-6 bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsxs("div", { className: "flex items-center gap-3 mb-4", children: [_jsx(Clock, { className: "w-6 h-6 text-blue-500" }), _jsx("h3", { className: "text-lg font-bold text-gray-900", children: "Y\u00EAu c\u1EA7u tham gia" }), _jsxs("span", { className: "text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full", children: [joinRequests.length, " \u0111ang ch\u1EDD"] })] }), _jsxs("div", { className: "space-y-2", children: [joinRequests.map((req) => (_jsxs("div", { className: "flex items-center justify-between gap-3 p-3 rounded-2xl border border-gray-100 bg-white", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-9 h-9 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-700 font-bold text-sm", children: req.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-bold text-gray-800", children: req.user?.name || req.userId }), _jsxs("div", { className: "text-[11px] text-gray-500", children: ["\u0110\u01B0\u1EE3c m\u1EDDi b\u1EDFi ", req.requestedBy?.name, " \u2022 ", new Date(req.createdAt).toLocaleString('vi-VN')] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Chấp nhận yêu cầu',
|
||||
message: `Chấp nhận ${req.user?.name || req.userId} vào tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await acceptJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể chấp nhận yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "p-2 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 disabled:opacity-50", "aria-label": "Accept", children: _jsx(Check, { className: "w-4 h-4" }) }), _jsx("button", { disabled: joinRequestActionId === req.id, onClick: async () => {
|
||||
if (!currentTour)
|
||||
return;
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Từ chối yêu cầu',
|
||||
message: `Từ chối ${req.user?.name || req.userId} tham gia tour?`,
|
||||
onConfirm: async () => {
|
||||
setJoinRequestActionId(req.id);
|
||||
try {
|
||||
await rejectJoinRequest(currentTour.id, req.id);
|
||||
setJoinRequests((prev) => prev.filter((r) => r.id !== req.id));
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', e.message || 'Không thể từ chối yêu cầu', 'error');
|
||||
}
|
||||
finally {
|
||||
setJoinRequestActionId(null);
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, className: "p-2 rounded-xl bg-red-50 text-red-700 hover:bg-red-100 disabled:opacity-50", "aria-label": "Reject", children: _jsx(X, { className: "w-4 h-4" }) })] })] }, req.id))), joinRequests.length === 0 && (_jsx("div", { className: "text-center py-8 text-sm text-gray-500", children: "Kh\u00F4ng c\u00F3 y\u00EAu c\u1EA7u tham gia n\u00E0o \u0111ang ch\u1EDD ph\u00EA duy\u1EC7t." }))] })] }), _jsxs("div", { className: "p-8 text-center bg-gray-50 rounded-3xl border border-dashed border-gray-200", children: [_jsx(Settings, { className: "w-10 h-10 text-gray-300 mx-auto mb-3" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng c\u00E0i \u0111\u1EB7t kh\u00E1c \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] })] }))] })] }), canEdit && (_jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||
setTargetLegId(null);
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan')
|
||||
setIsAddLocationOpen(true);
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], onRemoveMember: (userId) => removeMember(currentTour.id, userId), onMemberAdded: () => fetchTour(currentTour.id) })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id }))] }));
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], joinRequests: joinRequests, onRemoveMember: (userId) => removeMember(currentTour.id, userId), onMemberAdded: () => fetchTour(currentTour.id), userRole: userRole || undefined })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id })), isMemberDetailOpen && selectedMember && (_jsxs("div", { className: "fixed inset-0 z-[2100] flex items-center justify-center p-4", children: [_jsx("div", { className: "absolute inset-0 bg-gray-900/60 backdrop-blur-sm", onClick: () => setIsMemberDetailOpen(false) }), _jsxs("div", { className: "relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold", children: selectedMember.user?.name?.charAt(0) || '?' }), _jsxs("div", { children: [_jsx("div", { className: "text-base font-bold text-gray-900", children: selectedMember.user?.name || 'Chưa đặt tên' }), _jsx("div", { className: "text-xs text-gray-500", children: selectedMember.user?.email }), _jsx("div", { className: "text-[10px] font-semibold text-gray-500", children: selectedMember.role })] })] }), (selectedMember.user?.phone || selectedMember.user?.address) && (_jsxs("div", { className: "mt-3 text-xs text-gray-600 space-y-1", children: [selectedMember.user?.phone && _jsxs("div", { children: ["\uD83D\uDCDE ", selectedMember.user.phone] }), selectedMember.user?.address && _jsxs("div", { children: ["\uD83D\uDCCD ", selectedMember.user.address] })] })), _jsxs("div", { className: "mt-4 flex justify-end gap-2", children: [_jsx("button", { onClick: () => setIsMemberDetailOpen(false), className: "px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100", children: "\u0110\u00F3ng" }), canEdit && selectedMember.role !== 'OWNER' && (_jsx("button", { onClick: async () => {
|
||||
if (!currentTour || !selectedMember)
|
||||
return;
|
||||
try {
|
||||
await removeMember(currentTour.id, selectedMember.userId);
|
||||
setIsMemberDetailOpen(false);
|
||||
}
|
||||
catch (e) {
|
||||
notificationModal.openModal('Thông báo', 'Không thể xóa thành viên', 'error');
|
||||
}
|
||||
}, className: "px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold", children: "X\u00F3a" })), canEdit && selectedMember.role === 'OWNER' && (_jsx("button", { onClick: () => {
|
||||
setIsMemberDetailOpen(false);
|
||||
setIsAddMemberOpen(true);
|
||||
}, className: "px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold", children: "M\u1EDDi th\u00EAm ng\u01B0\u1EDDi" }))] })] })] })), _jsx(ConfirmModal, { isOpen: confirmState.open, title: confirmState.title, message: confirmState.message, onConfirm: () => confirmState.onConfirm?.(), onCancel: () => setConfirmState({ open: false }) }), _jsx(NotificationModal, { isOpen: notificationModal.modalState?.isOpen ?? false, title: notificationModal.modalState?.title, message: notificationModal.modalState?.message, type: notificationModal.modalState?.type, onConfirm: () => notificationModal.closeModal() })] }));
|
||||
};
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+25
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
export interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
export declare const NotificationModal: React.FC<NotificationModalProps>;
|
||||
export declare const useNotificationModal: () => {
|
||||
modalState: {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: "info" | "success" | "warning" | "error";
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
openModal: (title: string, message: string, type?: "info" | "success" | "warning" | "error", onConfirm?: () => void, onCancel?: () => void) => () => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
export default NotificationModal;
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
const Icons = {
|
||||
info: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M12 16v-4", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("path", { d: "M12 8h.01", stroke: "currentColor", strokeWidth: "3", strokeLinecap: "round" })] })),
|
||||
success: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("path", { d: "M8 12l2.5 2.5L15.5 9", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] })),
|
||||
warning: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("path", { d: "M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "12", y1: "9", x2: "12", y2: "13", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("circle", { cx: "12", cy: "17", r: "1", fill: "currentColor" })] })),
|
||||
error: (props) => (_jsxs("svg", { ...props, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "2" }), _jsx("line", { x1: "15", y1: "9", x2: "9", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }), _jsx("line", { x1: "9", y1: "9", x2: "15", y2: "15", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })] })),
|
||||
};
|
||||
const defaultProps = {
|
||||
title: 'Thông báo',
|
||||
type: 'info',
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Hủy',
|
||||
};
|
||||
export const NotificationModal = ({ isOpen, title = defaultProps.title, message, onConfirm, onCancel, type = defaultProps.type, confirmButtonText = defaultProps.confirmButtonText, cancelButtonText = defaultProps.cancelButtonText, }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { bg: '#d4edda', border: '#c3e6cb', text: '#155724' };
|
||||
case 'warning':
|
||||
return { bg: '#fff3cd', border: '#ffeeba', text: '#856404' };
|
||||
case 'error':
|
||||
return { bg: '#f8d7da', border: '#f5c6cb', text: '#721c24' };
|
||||
default:
|
||||
return { bg: '#e2e3e5', border: '#d6d8db', text: '#383d41' };
|
||||
}
|
||||
};
|
||||
const styles = getTypeStyles();
|
||||
const getAnimationClass = () => {
|
||||
if (!isOpen)
|
||||
return 'opacity-0 translate-y-4';
|
||||
if (isAnimating && onCancel)
|
||||
return 'animate-fade-out';
|
||||
return 'animate-fade-in';
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
setIsAnimating(true);
|
||||
onConfirm?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setIsAnimating(true);
|
||||
onCancel?.();
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
};
|
||||
if (!isOpen)
|
||||
return null;
|
||||
return (_jsx("div", { className: "fixed inset-0 bg-black/50 flex items-center justify-center z-[100] p-4", children: _jsxs("div", { className: `bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-300 ${getAnimationClass()}`, role: "alertdialog", "aria-modal": "true", "aria-labelledby": "modal-title", "aria-describedby": "modal-message", children: [_jsx("div", { className: `p-6 border-b ${styles.border}`, children: _jsxs("div", { className: "flex items-center gap-3", children: [type === 'success' && _jsx(Icons.success, { className: "w-5 h-5 text-green-600" }), type === 'warning' && _jsx(Icons.warning, { className: "w-5 h-5 text-yellow-600" }), type === 'error' && _jsx(Icons.error, { className: "w-5 h-5 text-red-600" }), type === 'info' && _jsx(Icons.info, { className: "w-5 h-5 text-blue-600" }), _jsx("h2", { id: "modal-title", className: `text-xl font-semibold ${styles.text}`, children: title })] }) }), _jsx("div", { className: "p-6", children: _jsx("p", { id: "modal-message", className: "text-gray-700 leading-relaxed", children: message }) }), _jsxs("div", { className: `px-6 py-4 flex justify-end gap-3 border-t ${styles.border}`, children: [onCancel && (_jsx("button", { onClick: handleCancel, className: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-md transition-colors", children: cancelButtonText })), onConfirm && (_jsx("button", { onClick: handleConfirm, className: `px-4 py-2 text-white rounded-md font-medium transition-colors ${type === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'}`, children: confirmButtonText }))] })] }) }));
|
||||
};
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState(null);
|
||||
const openModal = (title, message, type = 'info', onConfirm, onCancel) => {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (onCancel) {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => prev ? { ...prev, isOpen: false } : null);
|
||||
};
|
||||
return {
|
||||
modalState,
|
||||
openModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
export default NotificationModal;
|
||||
//# sourceMappingURL=NotificationModal.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+177
-1
@@ -12,7 +12,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
};
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query } from '@nestjs/common';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
import 'dotenv/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -126,6 +126,11 @@ let TourController = class TourController {
|
||||
note: 'Chặng khởi đầu'
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
participants: {
|
||||
include: { user: { select: { id: true, name: true, email: true } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -337,6 +342,28 @@ let TourController = class TourController {
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
}
|
||||
let currentRole = req.user.tourParticipation?.role;
|
||||
if (!currentRole) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
currentRole = participation?.role;
|
||||
}
|
||||
if (!currentRole || !(currentRole === 'OWNER' || currentRole === 'MANAGER')) {
|
||||
const joinRequest = await this.prisma.joinRequest.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: body.userId,
|
||||
requestedById: req.user.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return { ...joinRequest, pendingApproval: true };
|
||||
}
|
||||
return this.prisma.tourParticipant.create({
|
||||
data: {
|
||||
tourId,
|
||||
@@ -346,6 +373,116 @@ let TourController = class TourController {
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
}
|
||||
async getJoinRequests(tourId, req) {
|
||||
const requests = await this.prisma.joinRequest.findMany({
|
||||
where: { tourId, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
async createJoinRequest(tourId, body, req) {
|
||||
const requestingUserId = body.userId || req.user.id;
|
||||
const existingParticipation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: requestingUserId } },
|
||||
});
|
||||
if (existingParticipation) {
|
||||
throw new BadRequestException('Người dùng này đã là thành viên của tour.');
|
||||
}
|
||||
const pendingRequest = await this.prisma.joinRequest.findFirst({
|
||||
where: { tourId, userId: requestingUserId, status: 'PENDING' },
|
||||
});
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
const joinRequest = await this.prisma.joinRequest.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: requestingUserId,
|
||||
requestedById: req.user.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
requestedBy: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return joinRequest;
|
||||
}
|
||||
async acceptJoinRequest(tourId, requestId, req) {
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền chấp nhận yêu cầu tham gia.');
|
||||
}
|
||||
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||
}
|
||||
if (joinRequest.status !== 'PENDING') {
|
||||
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||
}
|
||||
const existing = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: joinRequest.userId } },
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.joinRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: 'REJECTED' },
|
||||
});
|
||||
return { success: true, message: 'Người dùng đã là thành viên, yêu cầu đã bị từ chối.' };
|
||||
}
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.tourParticipant.create({
|
||||
data: {
|
||||
tourId,
|
||||
userId: joinRequest.userId,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
}),
|
||||
this.prisma.joinRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: 'ACCEPTED' },
|
||||
}),
|
||||
]);
|
||||
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
|
||||
}
|
||||
async rejectJoinRequest(tourId, requestId, req) {
|
||||
let role = req.user.tourParticipation?.role;
|
||||
if (!role) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId: req.user.id } },
|
||||
});
|
||||
role = participation?.role;
|
||||
}
|
||||
if (!role || !(role === 'OWNER' || role === 'MANAGER')) {
|
||||
throw new ForbiddenException('Bạn không có quyền từ chối yêu cầu tham gia.');
|
||||
}
|
||||
const joinRequest = await this.prisma.joinRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
});
|
||||
if (!joinRequest || joinRequest.tourId !== tourId) {
|
||||
throw new NotFoundException('Không tìm thấy yêu cầu tham gia.');
|
||||
}
|
||||
if (joinRequest.status !== 'PENDING') {
|
||||
throw new BadRequestException('Yêu cầu này đã được xử lý trước đó.');
|
||||
}
|
||||
await this.prisma.joinRequest.update({
|
||||
where: { id: requestId },
|
||||
data: { status: 'REJECTED' },
|
||||
});
|
||||
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
|
||||
}
|
||||
async removeMember(tourId, userId) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId } },
|
||||
@@ -459,6 +596,45 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "addMember", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Get(':tourId/join-requests'),
|
||||
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||
__param(1, Req()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "getJoinRequests", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Post(':tourId/join-requests'),
|
||||
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||
__param(1, Body()),
|
||||
__param(2, Req()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "createJoinRequest", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Post(':tourId/join-requests/:requestId/accept'),
|
||||
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||
__param(1, Param('requestId')),
|
||||
__param(2, Req()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "acceptJoinRequest", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Post(':tourId/join-requests/:requestId/reject'),
|
||||
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||
__param(1, Param('requestId')),
|
||||
__param(2, Req()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "rejectJoinRequest", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Delete(':tourId/members/:userId'),
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -21,6 +21,10 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
if (!user || !tourId) {
|
||||
throw new ForbiddenException("Thông tin xác thực hoặc mã Tour không hợp lệ.");
|
||||
}
|
||||
if (path.includes('/join-requests') && request.method === 'POST' && (!request.params.requestId || /\/join-requests\/[^/]+\/(accept|reject)$/.test(path))) {
|
||||
request.tourParticipation = null;
|
||||
return true;
|
||||
}
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: {
|
||||
tourId_userId: {
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAG1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAOD,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAGD,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAGjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AAlDY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CAkDzB"}
|
||||
{"version":3,"file":"rbac.middleware.js","sourceRoot":"","sources":["../rbac.middleware.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAG7C,IAAM,aAAa,GAAnB,MAAM,aAAa;IACxB,YAAoB,MAAqB;QAArB,WAAM,GAAN,MAAM,CAAe;IAAG,CAAC;IAE7C,KAAK,CAAC,WAAW,CAAC,OAAyB;QACzC,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;QAE1B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,EAAE,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;QAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC;QAEzB,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACrB,MAAM,IAAI,kBAAkB,CAAC,+CAA+C,CAAC,CAAC;QAChF,CAAC;QAED,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,IAAI,0CAA0C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACzJ,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;YACjC,OAAO,IAAI,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC,UAAU,CAAC;YACjE,KAAK,EAAE;gBACL,aAAa,EAAE;oBACb,MAAM,EAAE,MAAM;oBACd,MAAM,EAAE,IAAI,CAAC,EAAE;iBAChB;aACF;SACF,CAAC,CAAC;QAEH,IAAI,CAAC,aAAa,EAAE,CAAC;YACnB,MAAM,IAAI,kBAAkB,CAAC,4CAA4C,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,CAAC,iBAAiB,GAAG,aAAa,CAAC;QAE1C,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC;QAChC,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAEjD,IACE,CAAC,IAAI,KAAK,mBAAmB,IAAI,IAAI,KAAK,aAAa,CAAC;YACxD,CAAC,UAAU,IAAI,aAAa,CAAC,EAC7B,CAAC;YACD,MAAM,IAAI,kBAAkB,CAAC,0DAA0D,CAAC,CAAC;QAC3F,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;CACF,CAAA;AA/CY,aAAa;IADzB,UAAU,EAAE;qCAEiB,aAAa;GAD9B,aAAa,CA+CzB"}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+5
-1
@@ -23,12 +23,16 @@ interface TourState {
|
||||
addMember: (tourId: string, member: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
}) => Promise<void>;
|
||||
}) => Promise<any>;
|
||||
removeMember: (tourId: string, userId: string) => Promise<void>;
|
||||
setActiveLegId: (id: string | null) => void;
|
||||
setMapCenter: (pos: [number, number]) => void;
|
||||
fetchTour: (id: string) => Promise<void>;
|
||||
fetchPublicTours: () => Promise<void>;
|
||||
createJoinRequest: (tourId: string, userId?: string) => Promise<any>;
|
||||
fetchJoinRequests: (tourId: string) => Promise<any[]>;
|
||||
acceptJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||
rejectJoinRequest: (tourId: string, requestId: string) => Promise<any>;
|
||||
}
|
||||
export declare const useTourStore: import("zustand").UseBoundStore<import("zustand").StoreApi<TourState>>;
|
||||
export {};
|
||||
|
||||
Vendored
+72
-3
@@ -65,7 +65,13 @@ export const useTourStore = create((set, get) => ({
|
||||
},
|
||||
body: JSON.stringify(tourData),
|
||||
});
|
||||
return await response.json();
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi tạo Tour');
|
||||
}
|
||||
const tour = await response.json();
|
||||
await get().fetchPublicTours();
|
||||
return tour;
|
||||
},
|
||||
updateTour: async (id, data) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
@@ -266,8 +272,71 @@ export const useTourStore = create((set, get) => ({
|
||||
},
|
||||
body: JSON.stringify(member),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Lỗi khi thêm thành viên');
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi thêm thành viên');
|
||||
}
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
},
|
||||
createJoinRequest: async (tourId, userId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi tạo yêu cầu tham gia');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
fetchJoinRequests: async (tourId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi tải yêu cầu tham gia');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
acceptJoinRequest: async (tourId, requestId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/accept`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi chấp nhận yêu cầu');
|
||||
}
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
},
|
||||
rejectJoinRequest: async (tourId, requestId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/join-requests/${requestId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || 'Lỗi khi từ chối yêu cầu');
|
||||
}
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ Tài liệu này mô tả kiến trúc tổng thể, mô hình dữ liệu và c
|
||||
|
||||
---
|
||||
|
||||
## 8. Cấu Trúc Thư Mục (Directory Structure)
|
||||
## 1. Cấu Trúc Thư Mục (Directory Structure)
|
||||
|
||||
```text
|
||||
/home/locpham/travelplanning/
|
||||
@@ -3,10 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<title>Travel Planner</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.284.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/react": "^18.3.12",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
"autoprefixer": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,130 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LandingPage } from './pages/LandingPage';
|
||||
import { ExploreMap } from './pages/ExploreMap';
|
||||
import { TourDetailPage } from './pages/TourDetailPage';
|
||||
import { SignupPage } from './pages/SignupPage';
|
||||
import { MyPhotosPage } from './pages/MyPhotosPage';
|
||||
import { useTourStore } from './store/useTourStore';
|
||||
import { ConfirmProvider } from './hooks/useConfirm';
|
||||
import { NotificationProvider } from './hooks/useNotification';
|
||||
|
||||
function App() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const viewTourId = params.get('viewTour');
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [currentPage, setCurrentPage] = useState<'landing' | 'explore' | 'tourDetail' | 'signup' | 'myPhotos'>(viewTourId ? 'tourDetail' : 'landing');
|
||||
const [currentTourId, setCurrentTourId] = useState<string | null>(viewTourId);
|
||||
const [isPublicTourView, setIsPublicTourView] = useState(!!viewTourId);
|
||||
|
||||
// Lấy action từ store
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const viewTourId = params.get('viewTour');
|
||||
|
||||
if (viewTourId) {
|
||||
// Không gọi replaceState ngay để tránh mất ID khi component re-render hoặc refresh
|
||||
} else {
|
||||
// Kiểm tra đăng nhập bình thường nếu không có tham số viewTour
|
||||
const token = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
setCurrentPage('explore'); // Chuyển đến bản đồ khám phá nếu đã đăng nhập
|
||||
} catch (e) {
|
||||
console.error("Lỗi khi phân tích dữ liệu người dùng từ localStorage", e);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setCurrentPage('landing'); // Quay về trang Landing nếu dữ liệu lỗi
|
||||
}
|
||||
} else {
|
||||
setCurrentPage('landing'); // Quay về trang Landing nếu chưa đăng nhập
|
||||
}
|
||||
}
|
||||
}, []); // Chỉ chạy một lần khi component mount
|
||||
|
||||
const handleLoginSuccess = (loggedInUser: any) => {
|
||||
setUser(loggedInUser);
|
||||
setCurrentPage('explore');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
setCurrentPage('landing');
|
||||
};
|
||||
|
||||
const handleViewTour = (tourId: string) => {
|
||||
setCurrentTourId(tourId);
|
||||
setIsPublicTourView(false); // Không phải chế độ công khai nếu đến từ bản đồ khám phá
|
||||
setCurrentPage('tourDetail');
|
||||
};
|
||||
|
||||
const handleBackFromTourDetail = () => {
|
||||
setCurrentTourId(null);
|
||||
setIsPublicTourView(false);
|
||||
// Quay về trang khám phá nếu đã đăng nhập, ngược lại quay về Landing
|
||||
if (user) {
|
||||
setCurrentPage('explore');
|
||||
} else {
|
||||
setCurrentPage('landing');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackFromSignup = () => {
|
||||
setCurrentPage('landing');
|
||||
};
|
||||
|
||||
const handleSignupSuccess = () => {
|
||||
setCurrentPage('landing'); // Quay về trang Landing sau khi đăng ký thành công
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<NotificationProvider>
|
||||
{(() => {
|
||||
if (currentPage === 'tourDetail') {
|
||||
return (
|
||||
<TourDetailPage
|
||||
tourId={currentTourId!}
|
||||
onBack={handleBackFromTourDetail}
|
||||
isPublicView={isPublicTourView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'explore') {
|
||||
return (
|
||||
<ExploreMap
|
||||
onBack={handleBackFromTourDetail}
|
||||
onLogout={handleLogout}
|
||||
user={user}
|
||||
onViewTour={handleViewTour}
|
||||
onOpenMyPhotos={() => setCurrentPage('myPhotos')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'myPhotos') {
|
||||
return (
|
||||
<MyPhotosPage onBack={() => setCurrentPage('explore')} />
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPage === 'signup') {
|
||||
return <SignupPage onBack={handleBackFromSignup} onSuccess={handleSignupSuccess} />;
|
||||
}
|
||||
|
||||
return <LandingPage onContinue={() => setCurrentPage('explore')} onGoToSignup={() => setCurrentPage('signup')} onGoToMap={() => setCurrentPage('explore')} onLoginSuccess={handleLoginSuccess} isInitialSetup={false} />;
|
||||
})()}
|
||||
</NotificationProvider>
|
||||
</ConfirmProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { X, MapPin, Loader2, Clock, Map as MapIcon, Navigation, Search } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -59,7 +61,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
||||
);
|
||||
};
|
||||
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation, isPublicView = false }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any, isPublicView?: boolean }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -77,9 +79,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
plannedEnd: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [hasNoResults, setHasNoResults] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const searchTimeout = useRef<any>(null);
|
||||
|
||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore();
|
||||
const { legs, addLocation, updateLocation, mapCenter, currentTour, userRole } = useTourStore();
|
||||
const notify = useNotification();
|
||||
|
||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||
useEffect(() => {
|
||||
@@ -96,7 +103,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
type: editingLocation.type || 'VISIT',
|
||||
legId: editingLocation.legId || '',
|
||||
note: editingLocation.note || '',
|
||||
expenseAmount: expense?.amount?.toString() || '',
|
||||
expenseAmount: expense?.amount ? Number(expense.amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".") : '',
|
||||
expenseCategory: expense?.category || 'OTHER',
|
||||
expenseDescription: expense?.description || '',
|
||||
expenseNote: expense?.note || '',
|
||||
@@ -117,11 +124,31 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
}
|
||||
}, [initialLegId, editingLocation, isOpen]);
|
||||
|
||||
// Memoize tọa độ để tránh việc bản đồ tự động reset tâm khi re-render (ví dụ khi gõ tìm kiếm)
|
||||
const currentCoords = useMemo<[number, number]>(
|
||||
() => [formData.latitude, formData.longitude],
|
||||
[formData.latitude, formData.longitude]
|
||||
);
|
||||
|
||||
// Tự động cập nhật vị trí bản đồ theo chặng được chọn (Lấy điểm cuối của chặng đó làm tham chiếu)
|
||||
useEffect(() => {
|
||||
if (isOpen && !formData.name) {
|
||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||
if (isOpen && !editingLocation && formData.legId && !formData.name) {
|
||||
const selectedLeg = legs.find(l => l.id === formData.legId);
|
||||
|
||||
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) {
|
||||
// Di chuyển đến địa điểm cuối cùng của chặng để người dùng thấy điểm nối tiếp
|
||||
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
latitude: lastLoc.latitude,
|
||||
longitude: lastLoc.longitude
|
||||
}));
|
||||
} else {
|
||||
// Nếu chặng chưa có điểm nào, mặc định dùng vị trí trung tâm hiện tại của tour
|
||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||
}
|
||||
}
|
||||
}, [isOpen, mapCenter]);
|
||||
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
|
||||
|
||||
// 2. Thực hiện các tính toán và hàm xử lý
|
||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||
@@ -129,6 +156,55 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||
|
||||
const handleSearchLocation = (query: string) => {
|
||||
setFormData(prev => ({ ...prev, name: query }));
|
||||
|
||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
setHasNoResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setHasNoResults(false);
|
||||
searchTimeout.current = setTimeout(async () => {
|
||||
try {
|
||||
// Loại bỏ countrycodes=vn để tìm kiếm rộng hơn, thêm namedetails=1 để lấy tên chính xác
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=15&addressdetails=1&namedetails=1&accept-language=vi`, {
|
||||
headers: {
|
||||
'Accept-Language': 'vi'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
setSearchResults(data);
|
||||
setHasNoResults(data.length === 0);
|
||||
} catch (e) {
|
||||
console.error("Lỗi tìm kiếm địa điểm:", e);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const selectSearchResult = (result: any) => {
|
||||
const lat = parseFloat(result.lat);
|
||||
const lon = parseFloat(result.lon);
|
||||
// Ưu tiên lấy tên từ namedetails nếu có, nếu không lấy phần đầu của display_name
|
||||
const locationName = result.namedetails?.name || result.display_name.split(',')[0];
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: locationName,
|
||||
address: result.display_name,
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}));
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||
|
||||
@@ -146,8 +222,60 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleUseCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
notify({ title: 'Thông báo', message: "Trình duyệt hoặc thiết bị của bạn không hỗ trợ định vị GPS.", type: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Kiểm tra môi trường Secure Context (HTTPS) - Bắt buộc cho Geolocation trên Mobile
|
||||
if (!window.isSecureContext) {
|
||||
alert("Tính năng định vị GPS yêu cầu kết nối bảo mật (HTTPS). Nếu bạn đang truy cập qua địa chỉ IP, vui lòng sử dụng HTTPS hoặc Localhost.");
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const latlng = L.latLng(pos.coords.latitude, pos.coords.longitude);
|
||||
const now = new Date();
|
||||
const formattedTime = format(now, "yyyy-MM-dd'T'HH:mm");
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
plannedStart: formattedTime
|
||||
}));
|
||||
|
||||
// Tự động thực hiện reverse geocoding để lấy tên địa điểm và địa chỉ
|
||||
handlePickLocation(latlng);
|
||||
},
|
||||
(err) => {
|
||||
let errorMessage = "Không thể lấy vị trí: ";
|
||||
switch(err.code) {
|
||||
case err.PERMISSION_DENIED:
|
||||
errorMessage += "Bạn đã từ chối quyền truy cập vị trí.";
|
||||
break;
|
||||
case err.POSITION_UNAVAILABLE:
|
||||
errorMessage += "Thông tin vị trí không khả dụng.";
|
||||
break;
|
||||
case err.TIMEOUT:
|
||||
errorMessage += "Hết thời gian chờ yêu cầu định vị.";
|
||||
break;
|
||||
default: errorMessage += err.message;
|
||||
}
|
||||
notify({ title: 'Lỗi định vị', message: errorMessage, type: 'error' });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true, // Ưu tiên dùng GPS thay vì Wifi/Cell tower
|
||||
timeout: 10000, // Chờ tối đa 10 giây
|
||||
maximumAge: 0 // Không dùng vị trí cũ trong cache
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -155,6 +283,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
try {
|
||||
const payload: any = {
|
||||
...formData,
|
||||
expenseAmount: formData.expenseAmount.replace(/\./g, ''), // Loại bỏ dấu chấm trước khi gửi
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
@@ -167,7 +296,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert('Lỗi khi lưu địa điểm');
|
||||
notify({ title: 'Lỗi', message: 'Lỗi khi lưu địa điểm', type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -187,22 +316,91 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
</div>
|
||||
|
||||
{/* Mini Map Picker */}
|
||||
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
||||
<div className="h-64 w-full rounded-3xl overflow-hidden mb-6 border border-gray-100 relative shadow-xl group">
|
||||
{/* Map Search Bar Overlay - Tích hợp tìm kiếm trực tiếp trên bản đồ */}
|
||||
<div className="absolute top-3 left-3 right-3 z-[1001] pointer-events-none">
|
||||
<div className="relative max-w-sm pointer-events-auto">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm địa điểm trên bản đồ..."
|
||||
className="w-full pl-11 pr-10 py-3 bg-white/95 backdrop-blur-md border border-white/20 rounded-2xl shadow-lg outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm font-bold text-gray-800"
|
||||
value={formData.name}
|
||||
onChange={e => handleSearchLocation(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
|
||||
{isSearching ? (
|
||||
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-blue-500" />
|
||||
) : formData.name && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFormData({...formData, name: ''}); setSearchResults([]); setHasNoResults(false); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown kết quả tìm kiếm ngay trong khung bản đồ */}
|
||||
{(searchResults.length > 0 || hasNoResults) && (
|
||||
<div className="absolute left-0 right-0 mt-2 bg-white/95 backdrop-blur-md border border-gray-100 rounded-2xl shadow-2xl overflow-hidden max-h-40 overflow-y-auto animate-in slide-in-from-top-2 duration-200">
|
||||
{hasNoResults ? (
|
||||
<div className="px-4 py-4 text-center text-gray-400 text-xs italic">Không tìm thấy địa điểm phù hợp...</div>
|
||||
) : (
|
||||
searchResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => selectSearchResult(result)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-50 last:border-0 transition-colors flex flex-col gap-0.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-xs text-gray-900 truncate">{result.namedetails?.name || result.display_name.split(',')[0]}</div>
|
||||
{result.type && (
|
||||
<span className="text-[8px] font-black uppercase text-blue-400 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100 shrink-0">{result.type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 truncate leading-tight">{result.display_name}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapContainer center={currentCoords} zoom={13} className="h-full w-full" zoomControl={false}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker position={[formData.latitude, formData.longitude]} />
|
||||
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
||||
<Marker position={currentCoords} />
|
||||
<MapPicker center={currentCoords} onPick={handlePickLocation} />
|
||||
</MapContainer>
|
||||
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
|
||||
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nút lấy vị trí và thời gian hiện tại - Chỉ dành cho OWNER/MANAGER khi thêm mới */}
|
||||
{!editingLocation && (userRole === 'OWNER' || userRole === 'MANAGER') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseCurrentLocation}
|
||||
className="w-full mb-6 py-4 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center gap-2 text-xs font-black uppercase tracking-widest border border-indigo-100 transition-all active:scale-95 shadow-sm"
|
||||
>
|
||||
<Navigation className="w-4 h-4 fill-current" /> Sử dụng vị trí & thời gian hiện tại
|
||||
</button>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
||||
<input
|
||||
required
|
||||
placeholder="Tên địa điểm..."
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 transition-all font-bold"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||
@@ -220,9 +418,13 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
<input type="text" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
placeholder="0"
|
||||
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
||||
value={formData.expenseAmount} onChange={e => {
|
||||
const rawValue = e.target.value.replace(/\D/g, ""); // Chỉ lấy số
|
||||
const formattedValue = rawValue.replace(/\B(?=(\d{3})+(?!\d))/g, "."); // Thêm dấu chấm
|
||||
setFormData({...formData, expenseAmount: formattedValue});
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
||||
@@ -269,7 +471,10 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
|
||||
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||
{legs.map(leg => (
|
||||
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
|
||||
<option key={leg.id} value={leg.id}>
|
||||
Chặng {leg.sequence}: {leg.note || 'Không có tên'}
|
||||
{leg.startDate ? ` (${format(parseISO(leg.startDate), 'dd/MM')})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1,16 +1,21 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, UserPlus, Loader2, Shield, Trash2 } from 'lucide-react';
|
||||
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
|
||||
interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
|
||||
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
||||
onRemoveMember?: (userId: string) => Promise<void>;
|
||||
onMemberAdded?: () => void;
|
||||
userRole?: string; // User's role in the tour
|
||||
isPublicView?: boolean; // New prop to indicate public view
|
||||
}
|
||||
|
||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], onRemoveMember, onMemberAdded }) => {
|
||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -19,18 +24,22 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
|
||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
|
||||
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setFetchError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||
@@ -60,19 +69,40 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
|
||||
const handleRemove = async (userId: string, memberName: string) => {
|
||||
if (!onRemoveMember) return;
|
||||
setConfirmTarget({ userId, name: memberName });
|
||||
setIsConfirmOpen(true);
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Xóa thành viên',
|
||||
message: `Bạn có chắc chắn muốn xóa ${memberName} khỏi tour?`
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await onRemoveMember(userId);
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRemove = async () => {
|
||||
if (!confirmTarget || !onRemoveMember) return;
|
||||
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
|
||||
if (!onMemberAdded) return;
|
||||
setActionLoading(reqId);
|
||||
try {
|
||||
await onRemoveMember(confirmTarget.userId);
|
||||
const endpoint = action === 'accept'
|
||||
? `/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||
: `/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||
}
|
||||
await onMemberAdded();
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||
notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
|
||||
} finally {
|
||||
setIsConfirmOpen(false);
|
||||
setConfirmTarget(null);
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,29 +111,32 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||
const body = canCreateDirectly
|
||||
? { userId: selectedUser, role }
|
||||
: { userId: selectedUser };
|
||||
const res = await fetch(`${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ userId: selectedUser, role }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || 'Thêm thành viên thất bại');
|
||||
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||
}
|
||||
onMemberAdded?.();
|
||||
await onMemberAdded?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Thêm thành viên thất bại');
|
||||
setSubmitError(err.message || 'Thao tác thất bại');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen || isPublicView) return null; // Do not render if public view
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center p-4">
|
||||
@@ -112,9 +145,11 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
<div className="p-5 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-blue-600" /> Thêm thành viên
|
||||
<UserPlus className="w-5 h-5 text-blue-600" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Chọn người dùng và phân quyền cho tour này.</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
@@ -163,31 +198,61 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
className="w-full pl-9 pr-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl outline-none text-sm"
|
||||
placeholder="Tìm theo tên hoặc email..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onBlur={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
{joinRequests.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-500" /> Đang chờ phê duyệt ({joinRequests.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(joinRequests as any[]).map((req) => (
|
||||
<div key={req.id} className="flex flex-col items-center gap-1 relative">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden">
|
||||
{req.user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
|
||||
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||
aria-label="Accept"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
|
||||
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||
aria-label="Reject"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{req.user?.name || req.userId}</span>
|
||||
<span className="text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200">PENDING</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as any)}
|
||||
>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
|
||||
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
|
||||
</select>
|
||||
</div>
|
||||
{canCreateDirectly && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold text-gray-700 ml-1">Phân quyền</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as any)}
|
||||
>
|
||||
<option value="OWNER">OWNER</option>
|
||||
<option value="MANAGER">MANAGER</option>
|
||||
<option value="MEMBER">MEMBER</option>
|
||||
<option value="MEMBER_NO_FINANCE">MEMBER_NO_FINANCE</option>
|
||||
<option value="VIEWER_ONLY">VIEWER_ONLY</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fetchError && (
|
||||
@@ -210,9 +275,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUser(u.id)}
|
||||
disabled={requestUserIds.has(u.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-2xl border transition-all ${
|
||||
isSelected ? 'border-blue-500 bg-blue-50/60' : 'border-gray-100 hover:border-blue-200'
|
||||
}`}
|
||||
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||
{u.name?.charAt(0) || '?'}
|
||||
@@ -250,30 +316,10 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-xl text-sm font-bold transition-all"
|
||||
>
|
||||
{submitting ? 'Đang thêm...' : 'Thêm vào tour'}
|
||||
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConfirmOpen && (
|
||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
|
||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Bạn có chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
Hủy
|
||||
</button>
|
||||
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, Upload, Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
|
||||
interface AddPhotoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const AddPhotoModal: React.FC<AddPhotoModalProps> = ({ isOpen, onClose, tourId, onSuccess }) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotification();
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
const newValidFiles: File[] = [];
|
||||
const newValidPreviews: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 1. Kiểm tra cơ bản: Tệp có dung lượng hay không
|
||||
if (!file || file.size === 0) {
|
||||
notify({ title: 'Lỗi tệp', message: `Tệp ${file.name} trống hoặc không khả dụng.`, type: 'error' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
|
||||
// 2. Kiểm tra tính toàn vẹn: Thử load ảnh vào bộ nhớ để xác nhận file "tồn tại" và đọc được
|
||||
const isValidImage = await new Promise<boolean>((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(true);
|
||||
img.onerror = () => resolve(false);
|
||||
img.src = previewUrl;
|
||||
});
|
||||
|
||||
if (isValidImage) {
|
||||
newValidFiles.push(file);
|
||||
newValidPreviews.push(previewUrl);
|
||||
} else {
|
||||
URL.revokeObjectURL(previewUrl); // Thu hồi ngay nếu không hợp lệ
|
||||
notify({ title: 'Lỗi ảnh', message: `Không thể đọc nội dung ảnh: ${file.name}. Vui lòng kiểm tra lại file.`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...newValidFiles]);
|
||||
setPreviews(prev => [...prev, ...newValidPreviews]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
// Thu hồi URL khi xóa khỏi danh sách chờ để giải phóng bộ nhớ
|
||||
URL.revokeObjectURL(previews[index]);
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
setPreviews(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: `Đã tải lên ${selectedFiles.length} ảnh.`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
fetchTour(tourId);
|
||||
if (onSuccess) onSuccess();
|
||||
onClose();
|
||||
// Giải phóng bộ nhớ sau khi hoàn tất
|
||||
previews.forEach(url => URL.revokeObjectURL(url));
|
||||
setSelectedFiles([]);
|
||||
setPreviews([]);
|
||||
} catch (error) {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Không thể tải ảnh lên. Vui lòng thử lại.',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2500] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative w-full max-w-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<ImageIcon className="w-6 h-6 text-blue-600" /> Tải ảnh lên
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-gray-200 rounded-[32px] p-10 flex flex-col items-center justify-center cursor-pointer hover:bg-blue-50/50 hover:border-blue-200 transition-all mb-6 group"
|
||||
>
|
||||
<input type="file" ref={fileInputRef} className="hidden" multiple accept="image/*" onChange={handleFileChange} />
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 mb-4 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<Upload className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-sm font-black text-gray-700">Nhấn để chọn ảnh</p>
|
||||
<p className="text-xs text-gray-400 mt-1 font-medium">Hỗ trợ JPG, PNG, WEBP</p>
|
||||
</div>
|
||||
|
||||
{previews.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto mb-6 pr-2">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3">Đã chọn {previews.length} tệp</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{previews.map((src, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-2xl overflow-hidden border border-gray-100 shadow-sm group">
|
||||
<img src={src} className="w-full h-full object-cover" alt="preview" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(idx)}
|
||||
className="absolute top-1.5 right-1.5 p-1.5 bg-red-500/80 backdrop-blur-sm text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={isUploading || selectedFiles.length === 0}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white font-black uppercase tracking-widest rounded-2xl flex items-center justify-center gap-2 shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
>
|
||||
{isUploading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tải lên'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Send, MessageSquare, User, Loader2, Trash2 } from 'lucide-react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
userName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface CommentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
locationId: string;
|
||||
locationName: string;
|
||||
onCommentAdded?: () => void; // Callback to update comment count on parent
|
||||
onCommentDeleted?: () => void;
|
||||
isPublicView?: boolean; // New prop to indicate public view
|
||||
}
|
||||
|
||||
export const CommentModal: React.FC<CommentModalProps> = ({ isOpen, onClose, locationId, locationName, onCommentAdded, onCommentDeleted, isPublicView = false }) => {
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const userRole = useTourStore(state => state.userRole);
|
||||
const currentUserId = React.useMemo(() => {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
return user.id;
|
||||
} catch { return null; }
|
||||
}, []);
|
||||
|
||||
const fetchComments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setComments(data.map((c: any) => ({
|
||||
id: c.id,
|
||||
userName: c.user?.name || 'Ẩn danh',
|
||||
content: c.content,
|
||||
createdAt: c.createdAt,
|
||||
userId: c.userId
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi tải bình luận:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !locationId) return;
|
||||
|
||||
fetchComments();
|
||||
|
||||
// Lắng nghe bình luận mới qua Proxy (không cần hardcode URL)
|
||||
const socket = io();
|
||||
socket.emit('joinTour', 'global'); // Hoặc logic join cụ thể
|
||||
|
||||
socket.on('commentAdded', (newCommentData: any) => {
|
||||
if (newCommentData.locationId === locationId) {
|
||||
setComments(prev => {
|
||||
// Tránh trùng lặp nếu chính mình gửi
|
||||
if (prev.find(c => c.id === newCommentData.id)) return prev;
|
||||
return [...prev, {
|
||||
id: newCommentData.id,
|
||||
userName: newCommentData.user?.name || 'Ẩn danh',
|
||||
content: newCommentData.content,
|
||||
createdAt: newCommentData.createdAt
|
||||
}];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => { socket.disconnect(); };
|
||||
}, [isOpen, locationId]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/locations/${locationId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ content: newComment })
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewComment('');
|
||||
fetchComments();
|
||||
onCommentAdded?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi gửi bình luận:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/locations/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
setComments(prev => prev.filter(c => c.id !== commentId));
|
||||
onCommentDeleted?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi xóa bình luận:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[5000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-white sticky top-0 z-10">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-gray-900 flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
Bình luận
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-1 truncate max-w-[250px]">{locationName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment List */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50/50">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="w-6 h-6 animate-spin text-blue-600" /></div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-400 italic text-sm">Chưa có bình luận nào.</div>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="flex gap-3 animate-in slide-in-from-left-2 duration-300">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 border border-blue-200">
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-white p-3 rounded-2xl rounded-tl-none border border-gray-100 shadow-sm">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<p className="text-xs font-black text-gray-900">{c.userName}</p>
|
||||
{(userRole === 'OWNER' || userRole === 'MANAGER' || c.userId === currentUserId) && (
|
||||
<button
|
||||
onClick={() => setConfirmState({ open: true, commentId: c.id })}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{c.content}</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1 ml-1 font-medium">
|
||||
{new Date(c.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-white border-t border-gray-100">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder={isPublicView ? 'Đăng nhập để bình luận...' : 'Viết bình luận...'}
|
||||
className="flex-1 bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-all"
|
||||
/>
|
||||
<button onClick={handleSend} disabled={!newComment.trim() || isPublicView} className="p-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 disabled:opacity-50 disabled:bg-gray-300 transition-all active:scale-95 shadow-lg shadow-blue-100">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={confirmState.open}
|
||||
title="Xóa bình luận"
|
||||
message="Bạn có chắc chắn muốn xóa bình luận này không? Hành động này sẽ không thể hoàn tác."
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmState.commentId);
|
||||
setConfirmState({ open: false, commentId: '' });
|
||||
}}
|
||||
onCancel={() => setConfirmState({ open: false, commentId: '' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, title, message, onConfirm, onCancel }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[6000] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onCancel} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500 shadow-inner">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-black text-gray-900 mb-2">{title || 'Xác nhận'}</h3>
|
||||
<p className="text-sm text-gray-500 mb-8 leading-relaxed">
|
||||
{message || 'Bạn có chắc chắn muốn thực hiện hành động này không?'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
Hủy bỏ
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="py-4 bg-red-600 hover:bg-red-700 text-white font-bold rounded-2xl shadow-lg shadow-red-100 transition-all active:scale-95"
|
||||
>
|
||||
Xác nhận
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { Trash2, Users, Tag as TagIcon } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
|
||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [adultCount, setAdultCount] = useState(2);
|
||||
const [childCount, setChildCount] = useState(1);
|
||||
const [childDiscount, setChildDiscount] = useState(30);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createTour = useTourStore((state) => state.createTour);
|
||||
|
||||
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [customTag, setCustomTag] = useState('');
|
||||
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
@@ -23,8 +31,7 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Không thể tải người dùng');
|
||||
@@ -47,13 +54,37 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomTag = () => {
|
||||
const tag = customTag.trim();
|
||||
if (tag && !selectedTags.includes(tag)) {
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
setCustomTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||
const tour = await createTour({
|
||||
title,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
memberIds,
|
||||
adultCount,
|
||||
childCount,
|
||||
childDiscount,
|
||||
tags: selectedTags
|
||||
});
|
||||
onSuccess(tour);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
@@ -84,6 +115,56 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2 flex items-center gap-2">
|
||||
<TagIcon className="w-4 h-4" /> Phân loại Tour
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-blue-600 text-white border-blue-600 shadow-md shadow-blue-100'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customTag}
|
||||
onChange={(e) => setCustomTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustomTag())}
|
||||
placeholder="Thêm nhãn tùy chỉnh..."
|
||||
className="flex-1 px-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-xs outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomTag}
|
||||
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all"
|
||||
>
|
||||
Thêm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Mô tả chuyến đi</label>
|
||||
<textarea
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 resize-none text-sm"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Viết vài dòng giới thiệu về hành trình..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||
@@ -105,6 +186,31 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 space-y-3">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-xs font-black uppercase tracking-wider">Cơ cấu đoàn & Định mức chi phí</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Người lớn</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={adultCount} onChange={e => setAdultCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Trẻ em</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={childCount} onChange={e => setChildCount(Number(e.target.value))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Giảm trẻ em %</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={childDiscount} onChange={e => setChildDiscount(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-400 italic font-medium">* Dùng để tính toán đơn giá bình quân trong báo cáo chi phí.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||
import { useTourStore } from './useTourStore.js';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CommentModal } from '@/components/CommentModal';
|
||||
|
||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||
if (!actual) return null;
|
||||
@@ -35,11 +38,72 @@ const formatTravelTime = (minutes: number) => {
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
|
||||
export const ItineraryTimeline = ({
|
||||
export const ItineraryTimeline = ({
|
||||
onAddLocation,
|
||||
onEditLocation
|
||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||
onEditLocation,
|
||||
isPublicView = false
|
||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void, isPublicView?: boolean }) => {
|
||||
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||
const currentTour = useTourStore(state => state.currentTour);
|
||||
const legs = useTourStore(state => state.legs);
|
||||
const userRole = useTourStore(state => state.userRole);
|
||||
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
||||
const addLeg = useTourStore(state => state.addLeg);
|
||||
const updateLeg = useTourStore(state => state.updateLeg);
|
||||
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
|
||||
const deleteLeg = useTourStore(state => state.deleteLeg);
|
||||
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||
|
||||
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
||||
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotification();
|
||||
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
||||
const [tempLegCount, setTempLegCount] = useState(3);
|
||||
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||
const [commentLocationId, setCommentLocationId] = useState('');
|
||||
const [commentLocationName, setCommentLocationName] = useState('');
|
||||
|
||||
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
const updatedLegs = currentLegs.map(leg => ({
|
||||
...leg,
|
||||
locations: leg.locations.map(loc =>
|
||||
loc.id === locationId
|
||||
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
||||
: loc
|
||||
)
|
||||
}));
|
||||
// Dùng setState của Zustand để cập nhật một phần dữ liệu
|
||||
useTourStore.setState({ legs: updatedLegs });
|
||||
};
|
||||
|
||||
const handleCommentDecrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
const updatedLegs = currentLegs.map(leg => ({
|
||||
...leg,
|
||||
locations: leg.locations.map(loc =>
|
||||
loc.id === locationId
|
||||
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
||||
: loc
|
||||
)
|
||||
}));
|
||||
useTourStore.setState({ legs: updatedLegs });
|
||||
};
|
||||
|
||||
// State cho Modal sửa chặng
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingLegData, setEditingLegData] = useState({
|
||||
id: '',
|
||||
note: '',
|
||||
description: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
});
|
||||
|
||||
const toggleComplete = async (locationId: string) => {
|
||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||
@@ -54,35 +118,65 @@ export const ItineraryTimeline = ({
|
||||
};
|
||||
|
||||
const handleDeclareLegs = async () => {
|
||||
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||
const count = parseInt(countStr || "0");
|
||||
if (count > 0 && currentTour) {
|
||||
await initializeLegs(currentTour.id, count);
|
||||
setTempLegCount(legs.length > 0 ? legs.length : 3);
|
||||
setIsLegCountModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeclareLegs = async () => {
|
||||
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
|
||||
await initializeLegs(currentTour.id, tempLegCount);
|
||||
}
|
||||
setIsLegCountModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditLeg = async (leg: any) => {
|
||||
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||
if (note !== null) {
|
||||
await updateLeg(leg.id, { note });
|
||||
setEditingLegData({
|
||||
id: leg.id,
|
||||
note: leg.note || "",
|
||||
description: leg.description || "",
|
||||
startDate: leg.startDate ? leg.startDate.split('T')[0] : "",
|
||||
endDate: leg.endDate ? leg.endDate.split('T')[0] : ""
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const saveLegEdit = async () => {
|
||||
if (editingLegData.id) {
|
||||
await updateLeg(editingLegData.id, {
|
||||
note: editingLegData.note,
|
||||
description: editingLegData.description,
|
||||
startDate: editingLegData.startDate || null,
|
||||
endDate: editingLegData.endDate || null
|
||||
});
|
||||
setIsEditModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLeg = async (legId: string) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa chặng này?")) {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Xóa chặng',
|
||||
message: 'Bạn có chắc chắn muốn xóa chặng này?'
|
||||
});
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLocation = async (id: string) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||
const isConfirmed = await confirm({
|
||||
title: 'Xóa địa điểm',
|
||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
|
||||
});
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
} catch (err: any) { alert(err.message); }
|
||||
} catch (err: any) {
|
||||
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,10 +206,19 @@ export const ItineraryTimeline = ({
|
||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm">
|
||||
{leg.sequence}
|
||||
</span>
|
||||
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm shrink-0">
|
||||
{leg.sequence}
|
||||
</span>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
|
||||
{leg.startDate && (
|
||||
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
|
||||
<CalendarIcon className="w-2.5 h-2.5" />
|
||||
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
|
||||
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{prevLegLastLoc && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
|
||||
@@ -124,7 +227,7 @@ export const ItineraryTimeline = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id)}
|
||||
@@ -158,7 +261,7 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDwellMinutes > 0 && (
|
||||
{totalDwellMinutes > 0 && ( // Always show dwell time
|
||||
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||
@@ -173,7 +276,7 @@ export const ItineraryTimeline = ({
|
||||
<Zap className="w-3 h-3" />
|
||||
Tối ưu
|
||||
</button>
|
||||
)}
|
||||
)} {/* Only show optimize button if canEdit */}
|
||||
</div>
|
||||
|
||||
{/* Vertical Line for the whole leg */}
|
||||
@@ -251,7 +354,7 @@ export const ItineraryTimeline = ({
|
||||
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
|
||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ</span>
|
||||
</div>
|
||||
{locationExpense.description && (
|
||||
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
|
||||
@@ -267,7 +370,18 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
|
||||
<div className="text-right flex flex-col items-end">
|
||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
||||
<button
|
||||
onClick={() => {
|
||||
setCommentLocationId(location.id);
|
||||
setCommentLocationName(location.name);
|
||||
setIsCommentModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100 mb-2"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
BÌNH LUẬN {location._count?.comments > 0 && `(${location._count.comments})`}
|
||||
</button>
|
||||
<div className="flex items-center text-sm font-black text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
@@ -276,7 +390,7 @@ export const ItineraryTimeline = ({
|
||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
||||
{canEdit && !isStartPoint && !isEndPoint && ( // Only show edit/delete if canEdit
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
@@ -320,7 +434,7 @@ export const ItineraryTimeline = ({
|
||||
)}
|
||||
|
||||
{/* Actions at the bottom of the list */}
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||
{canEdit && ( // Only show these buttons if canEdit
|
||||
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||
<button
|
||||
onClick={handleDeclareLegs}
|
||||
@@ -338,6 +452,135 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Khai báo số chặng (Popover) */}
|
||||
{isLegCountModalOpen && (
|
||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-gray-900">Số chặng lộ trình</h3>
|
||||
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6 leading-relaxed">
|
||||
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-6 mb-8">
|
||||
<button
|
||||
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-4xl font-black text-blue-600 w-12 text-center">{tempLegCount}</span>
|
||||
<button
|
||||
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
|
||||
className="w-12 h-12 rounded-2xl border-2 border-gray-100 flex items-center justify-center text-2xl font-bold text-gray-400 hover:border-blue-200 hover:text-blue-600 transition-all"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={confirmDeclareLegs}
|
||||
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
>
|
||||
Xác nhận
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
|
||||
<div className="relative w-full max-w-md bg-white rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-gray-900">Chỉnh sửa Chặng</h3>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLegData.note}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
||||
placeholder="VD: Ngày 1: Khởi hành"
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<AlignLeft className="w-3 h-3" /> Mô tả chi tiết
|
||||
</label>
|
||||
<textarea
|
||||
value={editingLegData.description}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
||||
placeholder="Mô tả các hoạt động chính trong chặng này..."
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">
|
||||
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingLegData.startDate}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingLegData.endDate}
|
||||
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
|
||||
className="w-full px-5 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mt-8">
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="py-4 bg-gray-100 hover:bg-gray-200 text-gray-600 font-bold rounded-2xl transition-all active:scale-95"
|
||||
>
|
||||
Hủy
|
||||
</button>
|
||||
<button
|
||||
onClick={saveLegEdit}
|
||||
className="py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
|
||||
>
|
||||
Lưu thay đổi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CommentModal
|
||||
isOpen={isCommentModalOpen}
|
||||
onClose={() => setIsCommentModalOpen(false)}
|
||||
locationId={commentLocationId}
|
||||
locationName={commentLocationName}
|
||||
isPublicView={isPublicView}
|
||||
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
||||
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,8 +22,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitc
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||
const response = await fetch(`/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationModal - Component hiển thị thông báo phản hồi cho người dùng
|
||||
*/
|
||||
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
title = 'Thông báo',
|
||||
message,
|
||||
type = 'info',
|
||||
onConfirm,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-12 h-12 text-green-500" />,
|
||||
error: <AlertCircle className="w-12 h-12 text-red-500" />,
|
||||
info: <Info className="w-12 h-12 text-blue-500" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-600 hover:bg-green-700 shadow-green-100',
|
||||
error: 'bg-red-600 hover:bg-red-700 shadow-red-100',
|
||||
info: 'bg-blue-600 hover:bg-blue-700 shadow-blue-100',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-center mb-5">
|
||||
{icons[type]}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
|
||||
<p className="text-gray-500 text-sm leading-relaxed mb-8">
|
||||
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`w-full py-4 text-white font-bold rounded-2xl transition-all shadow-lg active:scale-95 ${colors[type]}`}
|
||||
>
|
||||
Đã hiểu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook để quản lý trạng thái của NotificationModal
|
||||
*/
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: 'Thông báo',
|
||||
message: '',
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
const openModal = (title: string, message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
setModalState({ isOpen: true, title, message, type });
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
};
|
||||
|
||||
return { modalState, openModal, closeModal };
|
||||
};
|
||||
@@ -14,8 +14,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
||||
const response = await fetch(`/api/v1/users`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||
@@ -34,8 +33,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
|
||||
const handleToggleBlock = async (id: string) => {
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||
await fetch(`/api/v1/users/block/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
@@ -48,8 +46,7 @@ export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
||||
const res = await fetch(`/api/v1/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<((options: ConfirmOptions) => Promise<boolean>) | undefined>(undefined);
|
||||
|
||||
export const ConfirmProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
resolve?: (value: boolean) => void;
|
||||
}>({ isOpen: false });
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setState({
|
||||
isOpen: true,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
const resolve = state.resolve;
|
||||
setState({ isOpen: false, resolve: undefined });
|
||||
resolve?.(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
const resolve = state.resolve;
|
||||
setState({ isOpen: false, resolve: undefined });
|
||||
resolve?.(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
isOpen={state.isOpen}
|
||||
title={state.title}
|
||||
message={state.message}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useConfirm = () => {
|
||||
const confirm = useContext(ConfirmContext);
|
||||
if (!confirm) throw new Error('useConfirm must be used within a ConfirmProvider');
|
||||
return confirm;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { NotificationModal } from '../components/NotificationModal';
|
||||
|
||||
interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<((options: NotificationOptions) => void) | undefined>(undefined);
|
||||
|
||||
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
}>({ isOpen: false });
|
||||
|
||||
const notify = useCallback(({ title, message, type = 'info' }: NotificationOptions) => {
|
||||
setState({
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setState(prev => ({ ...prev, isOpen: false }));
|
||||
}, []);
|
||||
|
||||
// Tự động đóng sau 3 giây nếu modal đang mở
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
handleClose();
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer); // Xóa timer nếu người dùng bấm nút đóng trước 3 giây hoặc thông báo mới đè lên
|
||||
}
|
||||
}, [state.isOpen, handleClose]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={notify}>
|
||||
{children}
|
||||
<NotificationModal
|
||||
isOpen={state.isOpen}
|
||||
title={state.title}
|
||||
message={state.message}
|
||||
type={state.type}
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNotification = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotification must be used within a NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,488 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Tooltip } from 'react-leaflet';
|
||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon, MapPin, Loader2 } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
|
||||
// Fix lỗi icon mặc định của Leaflet
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
// Component Helper để cập nhật tâm bản đồ khi vị trí người dùng thay đổi
|
||||
function RecenterMap({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.setView(position, map.getZoom());
|
||||
}, [position, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component Helper để đóng menu khi tương tác với bản đồ
|
||||
function MapEvents({ onMapAction }: { onMapAction: () => void }) {
|
||||
useMapEvents({
|
||||
click: () => onMapAction(),
|
||||
movestart: onMapAction,
|
||||
dragstart: onMapAction,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Component Helper để theo dõi sự di chuyển của người dùng trên bản đồ
|
||||
function MapTracker() {
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
useMapEvents({
|
||||
moveend: (e) => {
|
||||
const map = e.target;
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const coords: [number, number] = [center.lat, center.lng];
|
||||
|
||||
setMapCenter(coords);
|
||||
// Lưu vị trí và mức zoom vào localStorage để sử dụng cho lần sau
|
||||
localStorage.setItem('map_view_state', JSON.stringify({ center: coords, zoom }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos }: { onBack: () => void, onLogout?: () => void, user?: any, onViewTour: (id: string) => void, onOpenMyPhotos: () => void }) => {
|
||||
// Tối ưu hóa việc lấy dữ liệu từ store bằng selectors để tránh re-render thừa
|
||||
const publicTours = useTourStore(state => state.publicTours);
|
||||
const fetchPublicTours = useTourStore(state => state.fetchPublicTours);
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const setMapCenter = useTourStore(state => state.setMapCenter);
|
||||
|
||||
const notify = useNotification();
|
||||
|
||||
// Khởi tạo vị trí từ localStorage nếu có, nếu không dùng mặc định (TP.HCM)
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved); } catch (e) { return null; }
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const [userPos, setUserPos] = useState<[number, number]>(initialViewState?.center || [10.7769, 106.7009]);
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); // State để điều khiển hiển thị dropdown lọc
|
||||
const [searchQuery, setSearchQuery] = useState(''); // State cho giá trị tìm kiếm
|
||||
const [suggestions, setSuggestions] = useState<{ type: 'tour' | 'location', id: string, name: string, lat?: number, lon?: number }[]>([]);
|
||||
const [isSearchingSuggestions, setIsSearchingSuggestions] = useState(false);
|
||||
|
||||
// Logic xử lý gợi ý tự động khi người dùng gõ
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (searchQuery.trim().length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchingSuggestions(true);
|
||||
try {
|
||||
// 1. Lọc các Tour hiện có khớp với từ khóa
|
||||
const tourMatches = publicTours
|
||||
.filter(t => t.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.map(t => ({ type: 'tour' as const, id: t.id, name: t.title }));
|
||||
|
||||
// 2. Tìm kiếm địa điểm thực tế trên bản đồ qua OpenStreetMap
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5&addressdetails=1&accept-language=vi`);
|
||||
const data = await res.json();
|
||||
|
||||
const locationMatches = data.map((item: any) => ({
|
||||
type: 'location' as const,
|
||||
id: item.place_id,
|
||||
name: item.display_name,
|
||||
lat: parseFloat(item.lat),
|
||||
lon: parseFloat(item.lon)
|
||||
}));
|
||||
|
||||
// Hợp nhất kết quả: Tour ưu tiên lên đầu
|
||||
setSuggestions([...tourMatches, ...locationMatches]);
|
||||
} catch (err) {
|
||||
console.error("Lỗi tìm kiếm gợi ý:", err);
|
||||
} finally {
|
||||
setIsSearchingSuggestions(false);
|
||||
}
|
||||
}, 500); // Debounce 500ms để tránh gọi API quá nhiều
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, publicTours]);
|
||||
|
||||
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
||||
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||
|
||||
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
|
||||
const allFilterTags = React.useMemo(() => {
|
||||
const tagsSet = new Set(availableTags);
|
||||
publicTours.forEach(tour => {
|
||||
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
|
||||
});
|
||||
return Array.from(tagsSet);
|
||||
}, [publicTours]);
|
||||
|
||||
// State cho menu chuột phải chia sẻ
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
|
||||
|
||||
const handleShare = (id: string, title: string) => {
|
||||
const shareUrl = `${window.location.origin}?viewTour=${id}`;
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: title,
|
||||
text: `Khám phá hành trình du lịch: ${title}`,
|
||||
url: shareUrl,
|
||||
}).catch(() => {});
|
||||
} else if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
||||
type: 'success'
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Giải pháp dự phòng cho môi trường không có HTTPS
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã sao chép liên kết chuyến đi vào bộ nhớ tạm. Bạn có thể gửi cho bạn bè ngay!',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (err) {}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
setShareMenu(null);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (s: any) => {
|
||||
if (s.type === 'tour') {
|
||||
onViewTour(s.id);
|
||||
} else if (s.lat && s.lon) {
|
||||
const pos: [number, number] = [s.lat, s.lon];
|
||||
setUserPos(pos);
|
||||
setMapCenter(pos);
|
||||
notify({
|
||||
title: 'Tìm thấy địa điểm',
|
||||
message: `Đã di chuyển bản đồ tới: ${s.name.split(',')[0]}`,
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
setSuggestions([]);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const filteredTours = React.useMemo(() => {
|
||||
if (!selectedFilterTag) return publicTours;
|
||||
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
||||
}, [publicTours, selectedFilterTag]);
|
||||
|
||||
useEffect(() => {
|
||||
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||
if (user || localStorage.getItem('token')) {
|
||||
fetchPublicTours();
|
||||
}
|
||||
|
||||
// Nếu không có vị trí lưu từ trước, mới yêu cầu lấy vị trí hiện tại của thiết bị
|
||||
if (!initialViewState) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const posArray: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPos(posArray);
|
||||
setMapCenter(posArray);
|
||||
},
|
||||
() => console.log("Không thể lấy vị trí người dùng")
|
||||
);
|
||||
} else {
|
||||
// Cập nhật store để đồng bộ với vị trí khởi tạo từ cache
|
||||
setMapCenter(initialViewState.center);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full relative">
|
||||
{/* Top Bar Container - Chứa tất cả các nút điều hướng phía trên */}
|
||||
<div className="absolute top-4 left-4 right-4 z-[1002] flex items-center justify-between pointer-events-none">
|
||||
{/* Nhóm bên trái: Quay lại và Thông tin vị trí */}
|
||||
<div className="flex items-center gap-3 pointer-events-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100"
|
||||
title="Quay lại"
|
||||
>
|
||||
<X className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
|
||||
{/* Nút lọc Tag và Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsFilterDropdownOpen(prev => !prev)}
|
||||
className="bg-white p-3 rounded-full shadow-xl hover:bg-gray-50 transition-all border border-gray-100 flex items-center justify-center"
|
||||
title="Lọc theo loại"
|
||||
>
|
||||
<Filter className="w-6 h-6 text-gray-800" />
|
||||
</button>
|
||||
|
||||
{/* Filter Dropdown Content */}
|
||||
{isFilterDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-3 bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 flex flex-col gap-2 max-w-[200px] z-[1003] animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5"> {/* Hiển thị tag theo chiều dọc */}
|
||||
<button
|
||||
onClick={() => { setSelectedFilterTag(null); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{allFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => { setSelectedFilterTag(tag === selectedFilterTag ? null : tag); setIsFilterDropdownOpen(false); }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Box - Thay thế div "Khám phá khu vực" */}
|
||||
<div className="relative flex items-center bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border border-white/20 flex-1 max-w-xs sm:max-w-md pointer-events-auto hidden sm:flex px-4 py-3">
|
||||
<Navigation className="w-4 h-4 text-blue-600 mr-2 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm kiếm địa điểm, tour..."
|
||||
className="flex-1 bg-transparent outline-none text-gray-800 text-sm font-medium"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isSearchingSuggestions && <Loader2 className="w-4 h-4 animate-spin text-blue-500 mr-2" />}
|
||||
{searchQuery && (
|
||||
<button onClick={() => { setSearchQuery(''); setSuggestions([]); }} className="p-1 text-gray-400 hover:text-gray-600 rounded-full">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dropdown danh sách gợi ý */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-3 bg-white/95 backdrop-blur-md rounded-2xl shadow-2xl border border-white/20 overflow-hidden z-[1003] animate-in slide-in-from-top-2 duration-200">
|
||||
{suggestions.map((s, idx) => (
|
||||
<button
|
||||
key={`${s.type}-${s.id}-${idx}`}
|
||||
onClick={() => handleSelectSuggestion(s)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 flex items-center gap-3 transition-colors border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<div className={`p-2 rounded-xl flex-shrink-0 ${s.type === 'tour' ? 'bg-blue-50 text-blue-600' : 'bg-green-50 text-green-600'}`}>
|
||||
{s.type === 'tour' ? <ImageIcon className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold text-gray-800 truncate">{s.name}</span>
|
||||
<span className="text-[10px] font-black uppercase text-gray-400 tracking-wider">
|
||||
{s.type === 'tour' ? 'Chuyến đi của bạn' : 'Địa điểm trên bản đồ'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nhóm bên phải: Các thao tác người dùng */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
{/* Nút Ảnh của tôi */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log("Đang mở Ảnh của tôi...");
|
||||
onOpenMyPhotos();
|
||||
}}
|
||||
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-50 text-blue-600 transition-all flex items-center gap-2 font-bold border border-blue-100"
|
||||
title="Ảnh của tôi"
|
||||
>
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Ảnh của tôi</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút tạo Tour mới */}
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="bg-green-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-green-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
title="Tạo Tour mới"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Tạo Tour</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút quản lý người dùng cho Admin */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsAdminModalOpen(true)}
|
||||
className="bg-blue-600 p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-blue-700 text-white transition-all flex items-center gap-2 font-bold"
|
||||
title="Quản lý hệ thống"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Hệ thống</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Nút đăng xuất */}
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="bg-white p-3 md:px-4 md:py-3 rounded-2xl shadow-xl hover:bg-red-50 hover:text-red-600 transition-all flex items-center gap-2 font-bold text-gray-700 border border-gray-100"
|
||||
title="Đăng xuất"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="hidden md:inline text-sm">Rời đi</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapContainer
|
||||
center={userPos}
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
preferCanvas={true}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap contributors'
|
||||
/>
|
||||
|
||||
{/* Theo dõi di chuyển bản đồ */}
|
||||
<MapTracker />
|
||||
{/* Đóng menu và dropdown khi tương tác bản đồ */}
|
||||
<MapEvents onMapAction={() => { setShareMenu(null); setSuggestions([]); setIsFilterDropdownOpen(false); }} />
|
||||
|
||||
{/* Tự động di chuyển bản đồ đến vị trí người dùng khi tìm thấy tọa độ */}
|
||||
<RecenterMap position={userPos} />
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{filteredTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
const markerPos = startLoc
|
||||
? [startLoc.latitude, startLoc.longitude] as [number, number]
|
||||
: userPos;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={tour.id}
|
||||
position={markerPos}
|
||||
eventHandlers={{
|
||||
click: () => onViewTour(tour.id),
|
||||
contextmenu: (e) => {
|
||||
// Kiểm tra quyền chia sẻ (OWNER, MANAGER, MEMBER)
|
||||
const role = tour.participants?.[0]?.role;
|
||||
const canShare = ['OWNER', 'MANAGER', 'MEMBER'].includes(role);
|
||||
|
||||
// Hiển thị menu tại vị trí chuột
|
||||
setShareMenu({
|
||||
x: e.containerPoint.x,
|
||||
y: e.containerPoint.y,
|
||||
id: tour.id,
|
||||
title: tour.title,
|
||||
canShare
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={L.divIcon({
|
||||
className: 'custom-bubble',
|
||||
html: `
|
||||
<div class="relative group">
|
||||
<div class="w-12 h-12 rounded-full border-4 border-white shadow-lg overflow-hidden transition-transform group-hover:scale-110">
|
||||
<img src="${tourImage}" class="w-full h-full object-cover" onerror="this.src='https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=100'"/>
|
||||
</div>
|
||||
<div class="absolute -bottom-1 -right-1 bg-blue-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24]
|
||||
})}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
|
||||
<div className="p-1 max-w-[180px]">
|
||||
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
|
||||
{tour.tags && tour.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{tour.tags.map((tag: string) => (
|
||||
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
|
||||
{tour.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
{/* Context Menu Chia sẻ */}
|
||||
{shareMenu && (
|
||||
<div
|
||||
className="absolute z-[2000] bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 w-48 animate-in zoom-in-95 duration-200"
|
||||
style={{ top: shareMenu.y, left: shareMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{shareMenu.canShare ? (
|
||||
<button
|
||||
onClick={() => handleShare(shareMenu.id, shareMenu.title)}
|
||||
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 transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4 text-blue-600" /> Sao chép liên kết
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic">Bạn không có quyền chia sẻ tour này</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Modal */}
|
||||
<UserManagementModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} />
|
||||
|
||||
{/* Create Tour Modal */}
|
||||
<CreateTourModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSuccess={(tour) => {
|
||||
fetchTour(tour.id);
|
||||
onViewTour(tour.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { LogIn, Compass, ArrowRight, Map as MapIcon, UserPlus, ShieldCheck } from 'lucide-react';
|
||||
import { LoginModal } from './LoginModal.js';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
|
||||
const TRAVEL_IMAGES = [
|
||||
"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?auto=format&fit=crop&q=80",
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user