47 Commits

Author SHA1 Message Date
3dtours 6026c2227a fix: UI của trang MyPhotoPage lỗi 2026-06-16 21:25:08 +07:00
3dtours 1eed9b00de feat: xem ảnh theo cấu trúc của lightroom 2026-06-16 20:38:40 +07:00
3dtours 1709638bfd optimize: xác nhận redis đã nén dữ liệu 2026-06-16 20:22:53 +07:00
3dtours 37b5d14d7e optimize: sử dụng redis để cache cho các response từ server 2026-06-16 20:17:17 +07:00
3dtours 2fabdb79df fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:55 +07:00
3dtours 00554224a1 fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user 2026-06-16 18:54:24 +07:00
3dtours 88b2182789 feat: thêm chức năng upload ảnh 2026-06-16 17:14:22 +07:00
3dtours 989d60643b feat: thêm nút tìm kiếm địa điểm cho phần tạo tour, phần thêm địa điểm 2026-06-16 15:45:26 +07:00
3dtours 1772ced959 feat: nút lọc filter tags dropdown 2026-06-16 15:35:45 +07:00
3dtours c005009da2 feat: Chuyển khám phá địa điểm thành box tìm kiếm địa điểm trên bản đồ 2026-06-16 15:28:10 +07:00
3dtours bfd18e05dd feat: tạo hook useNotification.tsx để dùng chung toàn hệ thống 2026-06-16 12:41:00 +07:00
3dtours 29d39ae7b0 feat: tạo hook useConfirm.tsx để dùng chung toàn hệ thống 2026-06-16 12:29:09 +07:00
3dtours 047170d4be feat: cho phép người dùng và người quản lí xóa bình luận 2026-06-16 12:21:44 +07:00
3dtours fc96ee9eb8 fix: tìm kiếm địa điểm với cách gõ tên trên bản đồ 2026-06-16 12:15:01 +07:00
3dtours 0a584a13c7 feat: tìm kiếm địa điểm trên bản đồ 2026-06-16 11:37:21 +07:00
3dtours f78c72ad60 fix: hiển thị mô tả và tên của hành trình có thêm tags 2026-06-16 11:21:43 +07:00
3dtours 8cca4a83ca fix: tags không có trong csdl gây nên lỗi crash backend 2026-06-16 11:15:39 +07:00
3dtours bc49e081c6 feat: cho phép lấy thời gian và địa điểm hiện tại để gán cho điểm 2026-06-16 10:55:18 +07:00
3dtours 9e26aabce6 Sửa lỗi hiển thị chi phí ở điểm của chặng 2026-06-16 10:36:38 +07:00
3dtours 3e1a5db1a6 Sửa lỗi hiển thị bảng liệt kê chi phí 2026-06-16 10:28:33 +07:00
3dtours baa83aad8a Sửa lỗi người nhận được line xem được nội dung Tour nhưng không xem được bình luận 2026-06-16 09:56:59 +07:00
3dtours 302a82e887 Sửa lỗi người nhận được liên kết chia sẻ không xem được nội dung Tour 2026-06-16 09:54:32 +07:00
3dtours 63da413b3c Sửa lỗi OWNER, MANAGER không thể chia sẻ link 2026-06-16 09:33:15 +07:00
3dtours 02c493f7e0 Thêm tính năng click chuột phải vào bong bóng để lấy link chia sẻ 2026-06-16 08:46:45 +07:00
3dtours b939fc959d Thêm tính năng hiển thị tooltip trên bong bóng Tour 2026-06-16 08:35:48 +07:00
3dtours 5464d90948 Thêm tính năng bình luận ở mỗi điểm của chặng 2026-06-16 08:27:54 +07:00
3dtours c5530f36df feat: cập nhật giao diện tour hiển thị mô tả trên banner 2026-06-15 21:28:41 +07:00
3dtours dcaf71032c Cài đặt tính năng thay đổi tên và mô tả tour 2026-06-15 21:22:05 +07:00
3dtours aec9112064 Cài đặt tính năng người quản lí cập nhật số người tham gia 2026-06-15 20:55:36 +07:00
3dtours 8dac79fc4b Cài đặt tính năng người quản lí có thể khai báo số lượng người tham gia 2026-06-15 20:52:43 +07:00
3dtours 75cf96898c Cài đặt tính năng in bảng thống kê ra pdf 2026-06-15 20:43:32 +07:00
3dtours f52580717f Cài đặt tính năng hiển thị chi phí 2026-06-15 20:20:34 +07:00
3dtours 879f20985c Chỉnh sửa ngày của hành trình là ngày của chặng đầu tiên và chặng hoàn thành 2026-06-15 19:57:54 +07:00
3dtours 4edfd0eb59 Sửa lỗi chỉnh sửa tiêu đề chặng và ngày giờ của chặng 2026-06-15 19:53:58 +07:00
3dtours 55f0a8e775 Sửa lỗi không thể khai báo số lượng chặng 2026-06-15 19:42:45 +07:00
3dtours 7812d1395b Sửa lỗi thành viên có thể gửi lời mời các users khác 2026-06-15 19:38:07 +07:00
3dtours c9ef98b2ff Sửa lỗi không truy cập IP từ ngoài 2026-06-15 19:31:03 +07:00
3dtours 71f4aa9ecd Sửa lỗi frontend không load dược danh sách thêm thành viên 2026-06-15 18:27:54 +07:00
3dtours 34e2af8825 Sửa lỗi frontend không kết nối với backend 2026-06-15 18:25:37 +07:00
3dtours 4716b841dc Sửa lỗi tái cấu trúc thư mục và khai báo import 2026-06-15 16:31:28 +07:00
3dtours 967f6b4f6a Phục hồi lại và chuẩn bị tái cấu trúc thư mục 2026-06-15 15:00:37 +07:00
3dtours c6341aa12f Thêm tính năng owner có thể thêm thành viên, member thêm thành viên sẽ pending 2026-06-14 22:14:40 +07:00
3dtours 7cc724a133 Thêm tính năng sửa tính năng pending bị lỗi 2026-06-14 21:07:21 +07:00
3dtours 8ff09eeeaf Thêm tính năng pending khi một thành viên thêm một người khác vào 2026-06-14 20:22:37 +07:00
3dtours 914a9cf243 Thêm tính năng thêm hoặc xóa thành viên ngay tại trang tạo tour 2026-06-14 18:39:37 +07:00
3dtours d084e806a1 Thêm thành viên bằng địa chỉ email 2026-06-14 18:27:12 +07:00
3dtours bb15b2bf15 Thêm tính năng xóa thành viên ra khỏi tour 2026-06-14 17:43:28 +07:00
120 changed files with 12303 additions and 4691 deletions
+8
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
-184
View File
@@ -1,184 +0,0 @@
import React, { useState, useEffect } from 'react';
import { X, Search, UserPlus, Loader2, Shield, ShieldAlert } from 'lucide-react';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
}
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState('');
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)}`, {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (!res.ok) throw new Error('Không thể tải danh sách người dùng');
const data = await res.json();
setUsers(data);
} catch (err: any) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery('');
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
}
}, [isOpen]);
const handleAdd = async () => {
if (!selectedUser) return;
setSubmitting(true);
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ userId: selectedUser, role }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'Thêm thành viên thất bại');
}
onClose();
} catch (err: any) {
alert(err.message);
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[2000] 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-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<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
</h2>
<p className="text-xs text-gray-500">Chọn người dùng phân quyền cho tour này.</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" />
</button>
</div>
<div className="p-5 space-y-4">
<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>
<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 && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{users.map((u) => {
const isSelected = selectedUser === u.id;
return (
<button
key={u.id}
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'
}`}
>
<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) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && users.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<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"
>
{submitting ? 'Đang thêm...' : 'Thêm vào tour'}
</button>
</div>
</div>
</div>
);
};
-91
View File
@@ -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;
-82
View File
@@ -1,82 +0,0 @@
import React, { useState } from 'react';
import { X, Map as MapIcon, Loader2 } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
const [title, setTitle] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore(state => state.createTour);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const tour = await createTour({ title, startDate, endDate });
onSuccess(tour);
onClose();
} catch (error) {
alert('Lỗi khi tạo tour');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] 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-md bg-white rounded-3xl shadow-2xl overflow-hidden p-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MapIcon className="w-6 h-6 text-blue-600" /> Tạo Tour mới
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input
required
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"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt"
/>
</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>
<input
type="date"
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"
value={startDate}
onChange={e => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input
type="date"
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"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/>
</div>
</div>
<button
disabled={isLoading}
className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
>
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Xác nhận tạo Tour'}
</button>
</form>
</div>
</div>
);
};
-77
View File
@@ -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
View File
@@ -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 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='&copy; 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>
);
};
-641
View File
@@ -1,641 +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
} = 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 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}
/>
)}
{/* Add Location Modal */}
{currentTour && (
<AddLocationModal
isOpen={isAddLocationOpen}
onClose={() => setIsAddLocationOpen(false)}
initialLegId={targetLegId || undefined}
editingLocation={editingLocation}
tourId={currentTour.id}
/>
)}
</div>
);
};
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>;
}
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
+10
View File
@@ -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>>;
}
+123
View File
@@ -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"}
+7
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"}
+23
View File
@@ -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;
}
+1519
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -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
}
}
+45
View File
@@ -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)
}
+25 -2
View File
@@ -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 thể dùng lại 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
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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/**/*"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

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

+208
View File
@@ -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;
+23
View File
@@ -3,6 +3,29 @@ 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;
}
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
export {};
+85 -11
View File
@@ -1,7 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { X, Search, UserPlus, Loader2, Shield } from 'lucide-react';
export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
import { useState, useEffect, useMemo } from 'react';
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);
@@ -10,6 +10,13 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState('');
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('');
@@ -21,7 +28,7 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
if (!res.ok)
throw new Error('Không thể tải danh sách người dùng');
const data = await res.json();
setUsers(data);
setUsers(Array.isArray(data) ? data : []);
}
catch (err) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
@@ -41,30 +48,83 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
setSubmitError('');
}
}, [isOpen]);
const handleRemove = async (userId, memberName) => {
if (!onRemoveMember)
return;
setConfirmTarget({ userId, name: memberName });
setIsConfirmOpen(true);
};
const confirmRemove = async () => {
if (!confirmTarget || !onRemoveMember)
return;
try {
await onRemoveMember(confirmTarget.userId);
}
catch (err) {
setSubmitError(err.message || 'Không thể xóa thành viên');
}
finally {
setIsConfirmOpen(false);
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;
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(`${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');
}
await onMemberAdded?.();
onClose();
}
catch (err) {
alert(err.message);
setSubmitError(err.message || 'Thao tác thất bại');
}
finally {
setSubmitting(false);
@@ -72,9 +132,23 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
};
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", { 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 })), 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: [users.map((u) => {
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 {
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
currentUserId = payload.sub;
}
catch {
currentUserId = null;
}
const isCurrentUser = currentUserId && p.userId === currentUserId;
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" }))] })] }), 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 && users.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' })] })] })] }));
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
+1 -1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -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 {};
+7
View File
@@ -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
+1
View File
@@ -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"}
+42 -6
View File
@@ -1,30 +1,66 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { X, Map as MapIcon, Loader2 } from 'lucide-react';
import { Trash2 } from 'lucide-react';
import { useTourStore } from './useTourStore.js';
export const CreateTourModal = ({ isOpen, onClose, onSuccess }) => {
const [title, setTitle] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createTour = useTourStore(state => state.createTour);
const createTour = useTourStore((state) => state.createTour);
const [members, setMembers] = useState([]);
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [error, setError] = useState('');
if (!isOpen)
return null;
const searchUsers = async (value) => {
setQuery(value);
if (!value.trim()) {
setResults([]);
return;
}
try {
const API_BASE = `http://${window.location.hostname}:3001`;
const res = await fetch(`${API_BASE}/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');
const data = await res.json();
setResults(Array.isArray(data) ? data : []);
}
catch (e) {
setResults([]);
setError(e.message || 'Không thể tải người dùng');
}
};
const confirmAddMember = (user) => {
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
setQuery('');
setResults([]);
setError('');
};
const removeMember = (userId) => {
setMembers((prev) => prev.filter((m) => m.id !== userId));
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const tour = await createTour({ title, startDate, endDate });
const memberIds = members.map((m) => m.id);
const tour = await createTour({ title, startDate, endDate, memberIds });
onSuccess(tour);
onClose();
}
catch (error) {
alert('Lỗi khi tạo tour');
catch (e) {
setError(e.message || 'Lỗi khi tạo tour');
}
finally {
setIsLoading(false);
}
};
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 p-8", children: [_jsxs("div", { className: "flex justify-between items-center mb-6", children: [_jsxs("h2", { className: "text-2xl font-bold text-gray-900 flex items-center gap-2", children: [_jsx(MapIcon, { className: "w-6 h-6 text-blue-600" }), " T\u1EA1o Tour m\u1EDBi"] }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: _jsx(X, { className: "w-6 h-6 text-gray-400" }) })] }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "T\u00EAn Tour" }), _jsx("input", { required: true, 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", value: title, onChange: e => setTitle(e.target.value), placeholder: "VD: Kh\u00E1m ph\u00E1 \u0110\u00E0 L\u1EA1t" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "B\u1EAFt \u0111\u1EA7u" }), _jsx("input", { type: "date", 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", value: startDate, onChange: e => setStartDate(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "K\u1EBFt th\u00FAc" }), _jsx("input", { type: "date", 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", value: endDate, onChange: e => setEndDate(e.target.value) })] })] }), _jsx("button", { disabled: isLoading, className: "w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2", children: isLoading ? _jsx(Loader2, { className: "w-5 h-5 animate-spin" }) : 'Xác nhận tạo Tour' })] })] })] }));
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 p-6", children: [_jsxs("div", { className: "flex justify-between items-center mb-4", children: [_jsx("h2", { className: "text-xl font-bold text-gray-900", children: "T\u1EA1o Tour m\u1EDBi" }), _jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-full transition-colors", children: "\u2715" })] }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "T\u00EAn Tour" }), _jsx("input", { required: true, 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", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VD: Kh\u00E1m ph\u00E1 \u0110\u00E0 L\u1EA1t" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "B\u1EAFt \u0111\u1EA7u" }), _jsx("input", { type: "date", 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", value: startDate, onChange: (e) => setStartDate(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-1", children: "K\u1EBFt th\u00FAc" }), _jsx("input", { type: "date", 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", value: endDate, onChange: (e) => setEndDate(e.target.value) })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-sm font-bold text-gray-700 mb-2", children: "Th\u00E0nh vi\u00EAn tham gia" }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [members.map((m) => (_jsxs("div", { className: "relative", 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 text-sm overflow-hidden", children: m.name }), _jsx("button", { type: "button", onClick: () => removeMember(m.id), className: "absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors", "aria-label": "Remove item", children: _jsx(Trash2, { size: 12 }) }), _jsx("div", { className: "text-[10px] text-center mt-1 max-w-[70px] truncate", children: m.name })] }, m.id))), _jsxs("div", { className: "relative", children: [_jsx("input", { className: "w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none", placeholder: "T\u00ECm email...", value: query, onChange: (e) => searchUsers(e.target.value) }), results.length > 0 && (_jsx("div", { className: "absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto", children: results.map((u) => (_jsxs("button", { type: "button", onClick: () => confirmAddMember(u), className: "w-full text-left px-3 py-2 text-sm hover:bg-blue-50", children: [_jsx("span", { className: "font-bold text-gray-900", children: u.name }), _jsx("span", { className: "block text-xs text-gray-500", children: u.email })] }, u.id))) }))] })] }), error && _jsx("p", { className: "text-xs text-red-600 mt-2", children: error })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2", children: isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour' })] })] })] }));
};
//# sourceMappingURL=CreateTourModal.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+12 -11
View File
@@ -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));
+1 -1
View File
File diff suppressed because one or more lines are too long
+59 -40
View File
@@ -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
+1 -1
View File
File diff suppressed because one or more lines are too long
+124 -5
View File
@@ -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 } = 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 })), 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
+1 -1
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -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;
+81
View File
@@ -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
File diff suppressed because one or more lines are too long
+198 -1
View File
@@ -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,128 @@ 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 } },
});
if (!participation) {
throw new NotFoundException('Thành viên này không có trong tour');
}
await this.prisma.tourParticipant.delete({
where: { tourId_userId: { tourId, userId } },
});
return { message: 'Đã xóa thành viên khỏi tour' };
}
};
__decorate([
UseGuards(JwtAuthGuard),
@@ -447,6 +596,54 @@ __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'),
__param(0, Param('tourId', ParseUUIDPipe)),
__param(1, Param('userId', ParseUUIDPipe)),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, String]),
__metadata("design:returntype", Promise)
], TourController.prototype, "removeMember", null);
TourController = __decorate([
Controller('v1/tours'),
__metadata("design:paramtypes", [PrismaService])
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -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: {
+1 -1
View File
@@ -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"}
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -23,11 +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 {};
+86 -3
View File
@@ -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`;
@@ -242,6 +248,20 @@ export const useTourStore = create((set, get) => ({
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
}
},
removeMember: async (tourId, userId) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok)
throw new Error('Lỗi khi xóa thành viên');
const { currentTour } = get();
if (currentTour)
get().fetchTour(currentTour.id);
},
addMember: async (tourId, member) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
@@ -252,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);
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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/
+2 -1
View File
@@ -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>
+33
View File
@@ -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

+130
View File
@@ -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>
+325
View File
@@ -0,0 +1,325 @@
import React, { useState, useEffect, useMemo } from '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 = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [role, setRole] = useState<'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY'>('MEMBER');
const [submitting, setSubmitting] = useState(false);
const [fetchError, setFetchError] = useState('');
const [submitError, setSubmitError] = useState('');
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 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');
const data = await res.json();
setUsers(Array.isArray(data) ? data : []);
} catch (err: any) {
setFetchError(err.message || 'Không thể tải danh sách người dùng');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isOpen) return;
fetchUsers();
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
setQuery('');
setSelectedUser(null);
setRole('MEMBER');
setFetchError('');
setSubmitError('');
}
}, [isOpen]);
const handleRemove = async (userId: string, memberName: string) => {
if (!onRemoveMember) return;
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 handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
if (!onMemberAdded) return;
setActionLoading(reqId);
try {
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) {
notify({ title: 'Lỗi', message: err.message || 'Thao tác thất bại', type: 'error' });
} finally {
setActionLoading(null);
}
};
const handleAdd = async () => {
if (!selectedUser) return;
setSubmitting(true);
setSubmitError('');
try {
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(body),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || data.error || 'Thao tác thất bại');
}
await onMemberAdded?.();
onClose();
} catch (err: any) {
setSubmitError(err.message || 'Thao tác thất bại');
} finally {
setSubmitting(false);
}
};
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">
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<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" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
</h2>
<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" />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
<div className="flex flex-wrap gap-3">
{participants.map((p) => {
const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null;
try {
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
currentUserId = payload.sub;
} catch {
currentUserId = null;
}
const isCurrentUser = currentUserId && p.userId === currentUserId;
const isOwner = p.role === 'OWNER';
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
return (
<div key={p.userId} className="flex flex-col items-center gap-1">
<div className="relative">
<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">
{p.user?.name?.charAt(0) || '?'}
</div>
{canRemove && (
<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"
>
<Trash2 size={10} />
</button>
)}
</div>
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
</div>
);
})}
{participants.length === 0 && (
<span className="text-xs text-gray-400">Chưa thành viên nào</span>
)}
</div>
</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>
)}
{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 && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError}
</div>
)}
{submitError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{submitError}
</div>
)}
{loading ? (
<div className="flex justify-center py-10"><Loader2 className="w-8 h-8 animate-spin text-blue-600" /></div>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{visibleUsers.map((u) => {
const isSelected = selectedUser === u.id;
return (
<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) || '?'}
</div>
<div className="flex-1 text-left">
<div className="text-sm font-bold text-gray-900">{u.name || 'Chưa đặt tên'}</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
<div className="text-[11px] text-gray-400">{u.phone || ''} {u.address ? `${u.address}` : ''}</div>
</div>
<div className="flex items-center gap-1 text-[10px] font-bold text-gray-500">
{u.isAdmin ? (
<span className="px-2 py-1 bg-purple-50 text-purple-600 rounded-md border border-purple-100">ADMIN</span>
) : (
<span className="px-2 py-1 bg-gray-50 text-gray-500 rounded-md border border-gray-100">USER</span>
)}
{isSelected && <Shield className="w-3 h-3 text-blue-600" />}
</div>
</button>
);
})}
{!loading && visibleUsers.length === 0 && (
<div className="text-center py-8 text-sm text-gray-500">Không tìm thấy người dùng phù hợp</div>
)}
</div>
)}
</div>
</div>
<div className="p-5 border-t border-gray-100 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2.5 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
Hủy
</button>
<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"
>
{submitting ? 'Đang xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
</button>
</div>
</div>
</div>
);
};
+168
View File
@@ -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>
);
};
+217
View File
@@ -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 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>
);
};
+53
View File
@@ -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>
);
};
+272
View File
@@ -0,0 +1,272 @@
import React, { useState } from 'react';
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[]>([]);
const [error, setError] = useState('');
if (!isOpen) return null;
const searchUsers = async (value: string) => {
setQuery(value);
if (!value.trim()) {
setResults([]);
return;
}
try {
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');
const data = await res.json();
setResults(Array.isArray(data) ? data : []);
} catch (e: any) {
setResults([]);
setError(e.message || 'Không thể tải người dùng');
}
};
const confirmAddMember = (user: any) => {
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
setQuery('');
setResults([]);
setError('');
};
const removeMember = (userId: string) => {
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,
description,
startDate,
endDate,
memberIds,
adultCount,
childCount,
childDiscount,
tags: selectedTags
});
onSuccess(tour);
onClose();
} catch (e: any) {
setError(e.message || 'Lỗi khi tạo tour');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-[2000] 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-md bg-white rounded-3xl shadow-2xl overflow-hidden p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"></button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
<input
required
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"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="VD: Khám phá Đà Lạt"
/>
</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"> 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>
<input
type="date"
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"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
<input
type="date"
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"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</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ấ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">
{members.map((m) => (
<div key={m.id} className="relative">
<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 text-sm overflow-hidden">
{m.name}
</div>
<button
type="button"
onClick={() => removeMember(m.id)}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
aria-label="Remove item"
>
<Trash2 size={12} />
</button>
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
</div>
))}
<div className="relative">
<input
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
placeholder="Tìm email..."
value={query}
onChange={(e) => searchUsers(e.target.value)}
/>
{results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
{results.map((u) => (
<button
key={u.id}
type="button"
onClick={() => confirmAddMember(u)}
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
>
<span className="font-bold text-gray-900">{u.name}</span>
<span className="block text-xs text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
</div>
</div>
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
>
{isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour'}
</button>
</form>
</div>
</div>
);
};
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" /> 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')}` }
});
+60
View File
@@ -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;
};
+64
View File
@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More