fix: tags không có trong csdl gây nên lỗi crash backend

This commit is contained in:
2026-06-16 11:15:39 +07:00
parent bc49e081c6
commit 8cca4a83ca
9 changed files with 189 additions and 10 deletions
+3 -1
View File
@@ -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,
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "tags" TEXT[];
+1
View File
@@ -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
View File
@@ -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,
+60 -2
View File
@@ -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">
+53 -2
View File
@@ -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}
+63 -1
View File
@@ -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"
+3 -2
View File
@@ -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,
},
},
},