fix: tags không có trong csdl gây nên lỗi crash backend
This commit is contained in:
Vendored
+3
-1
@@ -211,12 +211,13 @@ let TourController = class TourController {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async createTour(body, req) {
|
||||
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
|
||||
const { title, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||
return this.prisma.tour.create({
|
||||
data: {
|
||||
title,
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
endDate: endDate ? new Date(endDate) : null,
|
||||
tags: tags || [],
|
||||
adultCount: adultCount || 1,
|
||||
childCount: childCount || 0,
|
||||
childDiscount: childDiscount || 0,
|
||||
@@ -384,6 +385,7 @@ let TourController = class TourController {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||
tags: body.tags,
|
||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
|
||||
@@ -90,6 +90,7 @@ model Tour {
|
||||
childCount Int @default(0)
|
||||
childDiscount Int @default(30)
|
||||
|
||||
tags String[]
|
||||
createdById String
|
||||
creator User @relation("TourCreator", fields: [createdById], references: [id])
|
||||
|
||||
|
||||
+3
-1
@@ -150,12 +150,13 @@ class TourController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async createTour(@Body() body: any, @Req() req: any) {
|
||||
const { title, startDate, endDate, adultCount, childCount, childDiscount } = body;
|
||||
const { title, startDate, endDate, adultCount, childCount, childDiscount, tags } = body;
|
||||
return this.prisma.tour.create({
|
||||
data: {
|
||||
title,
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
endDate: endDate ? new Date(endDate) : null,
|
||||
tags: tags || [],
|
||||
adultCount: adultCount || 1,
|
||||
childCount: childCount || 0,
|
||||
childDiscount: childDiscount || 0,
|
||||
@@ -368,6 +369,7 @@ class TourController {
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
startDate: body.startDate ? new Date(body.startDate) : undefined,
|
||||
tags: body.tags,
|
||||
endDate: body.endDate ? new Date(body.endDate) : undefined,
|
||||
adultCount: body.adultCount !== undefined ? Number(body.adultCount) : undefined,
|
||||
childCount: body.childCount !== undefined ? Number(body.childCount) : undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, Users } from 'lucide-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 }) => {
|
||||
@@ -12,6 +12,10 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
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[]>([]);
|
||||
@@ -49,6 +53,20 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const addCustomTag = () => {
|
||||
const tag = customTag.trim();
|
||||
if (tag && !selectedTags.includes(tag)) {
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
setCustomTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
@@ -62,7 +80,8 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
memberIds,
|
||||
adultCount,
|
||||
childCount,
|
||||
childDiscount
|
||||
childDiscount,
|
||||
tags: selectedTags
|
||||
});
|
||||
onSuccess(tour);
|
||||
onClose();
|
||||
@@ -140,6 +159,45 @@ export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolea
|
||||
<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 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-2">Thành viên tham gia</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
@@ -5,7 +5,7 @@ const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerCluste
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2 } from 'lucide-react';
|
||||
import { X, Navigation, Image as ImageIcon, LogOut, Settings, Edit2, Trash2, Share2, Filter, Tag as TagIcon } from 'lucide-react';
|
||||
import { UserManagementModal } from '@/components/UserManagementModal';
|
||||
import { NotificationModal, useNotificationModal } from '@/components/NotificationModal';
|
||||
import { CreateTourModal } from '../components/CreateTourModal';
|
||||
@@ -79,6 +79,18 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const [selectedFilterTag, setSelectedFilterTag] = useState<string | null>(null);
|
||||
const availableTags = ['Văn hóa', 'Mạo hiểm', 'Nghỉ dưỡng', 'Thư giãn', 'Ẩm thực', 'Khám phá', 'Gia đình'];
|
||||
|
||||
// Tổng hợp nhãn từ danh sách Tour đang có để hiển thị bộ lọc đầy đủ (bao gồm cả nhãn tùy chỉnh)
|
||||
const allFilterTags = React.useMemo(() => {
|
||||
const tagsSet = new Set(availableTags);
|
||||
publicTours.forEach(tour => {
|
||||
tour.tags?.forEach((tag: string) => tagsSet.add(tag));
|
||||
});
|
||||
return Array.from(tagsSet);
|
||||
}, [publicTours]);
|
||||
|
||||
// State cho menu chuột phải chia sẻ
|
||||
const [shareMenu, setShareMenu] = useState<{ x: number, y: number, id: string, title: string, canShare: boolean } | null>(null);
|
||||
|
||||
@@ -109,6 +121,11 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
setShareMenu(null);
|
||||
};
|
||||
|
||||
const filteredTours = React.useMemo(() => {
|
||||
if (!selectedFilterTag) return publicTours;
|
||||
return publicTours.filter(tour => tour.tags?.includes(selectedFilterTag));
|
||||
}, [publicTours, selectedFilterTag]);
|
||||
|
||||
useEffect(() => {
|
||||
// Chỉ fetch dữ liệu khi người dùng đã đăng nhập và có token
|
||||
if (user || localStorage.getItem('token')) {
|
||||
@@ -182,6 +199,33 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bộ lọc theo Tag */}
|
||||
<div className="absolute top-24 left-6 z-[1000] flex flex-col gap-2 pointer-events-none">
|
||||
<div className="bg-white/90 backdrop-blur-md p-2 rounded-2xl shadow-xl border border-white/20 pointer-events-auto flex flex-col gap-2 max-w-[200px]">
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100 mb-1">
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
<span className="text-[11px] font-black uppercase text-gray-500 tracking-wider">Lọc theo loại</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedFilterTag(null)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${!selectedFilterTag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
Tất cả
|
||||
</button>
|
||||
{allFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setSelectedFilterTag(tag === selectedFilterTag ? null : tag)}
|
||||
className={`px-2.5 py-1 rounded-lg text-[10px] font-bold transition-all ${selectedFilterTag === tag ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapContainer
|
||||
center={userPos}
|
||||
zoom={mapZoom}
|
||||
@@ -203,7 +247,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
<RecenterMap position={userPos} />
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{publicTours.map((tour) => {
|
||||
{filteredTours.map((tour) => {
|
||||
const startLoc = tour.legs?.[0]?.locations?.[0];
|
||||
const tourImage = tour.photos?.[0]?.imageUrl || `https://picsum.photos/seed/${tour.id}/200/200`;
|
||||
const markerPos = startLoc
|
||||
@@ -250,6 +294,13 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour }: { onBack: ()
|
||||
<Tooltip direction="top" offset={[0, -20]} opacity={1}>
|
||||
<div className="p-1 max-w-[180px]">
|
||||
<div className="font-black text-blue-600 text-[11px] mb-0.5 uppercase tracking-tight truncate">{tour.title}</div>
|
||||
{tour.tags && tour.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{tour.tags.map((tag: string) => (
|
||||
<span key={tag} className="px-1.5 py-0.5 bg-blue-50 text-blue-500 rounded text-[8px] font-bold border border-blue-100">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tour.description && (
|
||||
<div className="text-[10px] text-gray-500 line-clamp-2 leading-tight italic">
|
||||
{tour.description}
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
Check,
|
||||
X,
|
||||
MessageSquare,
|
||||
Share2
|
||||
Share2,
|
||||
Tag as TagIcon
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
|
||||
@@ -188,6 +189,10 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
const [adultCountInput, setAdultCountInput] = useState(currentTour?.adultCount ?? 0);
|
||||
const [childCountInput, setChildCountInput] = useState(currentTour?.childCount ?? 0);
|
||||
const [childDiscountInput, setChildDiscountInput] = useState(currentTour?.childDiscount ?? 0);
|
||||
const [tagsInput, setTagsInput] = useState<string[]>(currentTour?.tags ?? []);
|
||||
const [customTag, setCustomTag] = useState('');
|
||||
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 [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
||||
const [commentLocationId, setCommentLocationId] = useState('');
|
||||
const [commentLocationName, setCommentLocationName] = useState('');
|
||||
@@ -197,6 +202,12 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
const fetchTour = useTourStore(state => state.fetchTour);
|
||||
const fetchPublicTourDetails = useTourStore(state => state.fetchPublicTourDetails); // New action
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTour) {
|
||||
setTagsInput(currentTour.tags || []);
|
||||
}
|
||||
}, [currentTour]);
|
||||
|
||||
// Hàm tối ưu để cập nhật số lượng bình luận mà không cần fetch lại toàn bộ Tour
|
||||
const handleCommentIncrement = (locationId: string) => {
|
||||
const currentLegs = useTourStore.getState().legs;
|
||||
@@ -424,6 +435,7 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
adultCount: adultCountInput,
|
||||
childCount: childCountInput,
|
||||
childDiscount: childDiscountInput,
|
||||
tags: tagsInput
|
||||
});
|
||||
notificationModal.openModal('Thành công', 'Đã cập nhật thông tin chuyến đi.', 'success');
|
||||
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
|
||||
@@ -514,6 +526,16 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
<div className="max-w-2xl mx-auto space-y-4">
|
||||
<h2 className="text-3xl font-black tracking-tight drop-shadow-md">{tourInfo.title}</h2>
|
||||
|
||||
{currentTour?.tags && currentTour.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentTour.tags.map((tag: string) => (
|
||||
<span key={tag} className="px-2.5 py-1 bg-white/20 backdrop-blur-md border border-white/30 rounded-lg text-[10px] font-black uppercase tracking-wider">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentTour?.description && (
|
||||
<p className="text-sm md:text-base text-white/90 max-w-xl line-clamp-3 md:line-clamp-none bg-black/20 backdrop-blur-sm p-4 rounded-2xl border border-white/10 italic leading-relaxed">
|
||||
<Quote className="w-4 h-4 inline-block mr-2 opacity-50" />
|
||||
@@ -999,6 +1021,46 @@ export const TourDetailPage = ({ onBack, tourId, isPublicView = false }: { onBac
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1 flex items-center gap-2">
|
||||
<TagIcon className="w-3 h-3" /> Phân loại Tour
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => setTagsInput(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag])}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
|
||||
tagsInput.includes(tag)
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: '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(), setTagsInput(prev => customTag.trim() && !prev.includes(customTag.trim()) ? [...prev, customTag.trim()] : prev), setCustomTag(''))}
|
||||
placeholder="Thêm nhãn tùy chỉnh..."
|
||||
className="flex-1 px-4 py-2.5 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (customTag.trim() && !tagsInput.includes(customTag.trim())) { setTagsInput([...tagsInput, customTag.trim()]); setCustomTag(''); } }}
|
||||
className="px-4 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-100"
|
||||
>
|
||||
Thêm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpdateTourInfo}
|
||||
className="w-full mt-6 px-4 py-4 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-100 active:scale-95"
|
||||
|
||||
@@ -31,12 +31,13 @@ export default defineConfig(({ mode }) => {
|
||||
https: httpsConfig,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user