fix: sửa lỗi hiển thị logo trên trang pdf

This commit is contained in:
2026-06-20 17:02:26 +07:00
parent e34d197dd0
commit ae97f061e8
21 changed files with 978 additions and 328 deletions
+77 -10
View File
@@ -516,11 +516,14 @@ let PublicTourController = class PublicTourController {
expenses: { expenses: {
include: { include: {
location: { select: { name: true, plannedStart: true } }, location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } } paidBy: { select: { id: true, name: true } }
} }
}, },
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
}, },
}, },
@@ -839,7 +842,10 @@ let TourController = class TourController {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
} }
} }
@@ -871,11 +877,14 @@ let TourController = class TourController {
expenses: { expenses: {
include: { include: {
location: { select: { name: true, plannedStart: true } }, location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } } paidBy: { select: { id: true, name: true } }
} }
}, },
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
}, },
}, },
@@ -931,6 +940,44 @@ let TourController = class TourController {
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { id: true, name: true, email: true } } },
}); });
} }
async updateMemberCounts(tourId, userId, body, req) {
const isSelf = req.user.id === userId;
const requesterParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: req.user.id } },
});
const canManage = requesterParticipation?.role === 'OWNER' || requesterParticipation?.role === 'MANAGER';
if (!canManage && !isSelf) {
throw new common_1.ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.');
}
const data = {};
if (body.adultCount !== undefined) {
if (body.adultCount < 1)
throw new common_1.BadRequestException('Số lượng người lớn tối thiểu là 1.');
data.adultCount = Number(body.adultCount);
}
if (body.childCount !== undefined) {
if (body.childCount < 0)
throw new common_1.BadRequestException('Số lượng trẻ em không được âm.');
data.childCount = Number(body.childCount);
}
if (body.role !== undefined && canManage) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
if (validRoles.includes(body.role)) {
data.role = body.role;
}
}
await Promise.all([
this.cacheManager.del(`user-role:${userId}:${tourId}`),
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId } },
data,
include: { user: { select: { id: true, name: true, email: true } } },
});
}
async getJoinRequests(tourId, req) { async getJoinRequests(tourId, req) {
const requests = await this.prisma.joinRequest.findMany({ const requests = await this.prisma.joinRequest.findMany({
where: { tourId, status: 'PENDING' }, where: { tourId, status: 'PENDING' },
@@ -1248,6 +1295,18 @@ __decorate([
__metadata("design:paramtypes", [String, Object, Object]), __metadata("design:paramtypes", [String, Object, Object]),
__metadata("design:returntype", Promise) __metadata("design:returntype", Promise)
], TourController.prototype, "addMember", null); ], TourController.prototype, "addMember", null);
__decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER, client_1.ParticipantRole.MEMBER, client_1.ParticipantRole.MEMBER_NO_FINANCE, client_1.ParticipantRole.VIEWER_ONLY),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
(0, common_1.Patch)(':tourId/members/:userId'),
__param(0, (0, common_1.Param)('tourId', common_1.ParseUUIDPipe)),
__param(1, (0, common_1.Param)('userId', common_1.ParseUUIDPipe)),
__param(2, (0, common_1.Body)()),
__param(3, (0, common_1.Req)()),
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, String, Object, Object]),
__metadata("design:returntype", Promise)
], TourController.prototype, "updateMemberCounts", null);
__decorate([ __decorate([
(0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER), (0, exports.Roles)(client_1.ParticipantRole.OWNER, client_1.ParticipantRole.MANAGER),
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard), (0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard, TourRoleGuard),
@@ -1515,7 +1574,10 @@ let RoutingController = class RoutingController {
tourId: currentLeg.tourId, tourId: currentLeg.tourId,
sequence: currentLeg.sequence - 1 sequence: currentLeg.sequence - 1
}, },
include: { locations: { orderBy: { plannedStart: 'asc' } } } include: { locations: { orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
] } }
}); });
if (prevLeg?.locations?.length) { if (prevLeg?.locations?.length) {
startAnchor = prevLeg.locations[prevLeg.locations.length - 1]; startAnchor = prevLeg.locations[prevLeg.locations.length - 1];
@@ -1568,7 +1630,10 @@ let RoutingController = class RoutingController {
}))); })));
const updatedLocations = await this.prisma.location.findMany({ const updatedLocations = await this.prisma.location.findMany({
where: { legId }, where: { legId },
orderBy: { plannedStart: 'asc' } orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
]
}); });
if (req.tourId) { if (req.tourId) {
await Promise.all([ await Promise.all([
@@ -1820,14 +1885,17 @@ let UserController = class UserController {
async getAllUsers(req, q) { async getAllUsers(req, q) {
const currentUserId = req.user?.id; const currentUserId = req.user?.id;
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: q where: {
isAnonymous: false,
...(q
? { ? {
OR: [ OR: [
{ name: { contains: q, mode: 'insensitive' } }, { name: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } },
], ],
} }
: undefined, : {}),
},
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true } select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
}); });
return users.filter((u) => u.id !== currentUserId); return users.filter((u) => u.id !== currentUserId);
@@ -1911,7 +1979,6 @@ let UserController = class UserController {
}; };
__decorate([ __decorate([
(0, common_1.Get)(), (0, common_1.Get)(),
(0, common_1.UseGuards)(admin_guard_1.AdminGuard),
__param(0, (0, common_1.Req)()), __param(0, (0, common_1.Req)()),
__param(1, (0, common_1.Query)('q')), __param(1, (0, common_1.Query)('q')),
__metadata("design:type", Function), __metadata("design:type", Function),
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Location" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "TourParticipant" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
+9 -3
View File
@@ -118,14 +118,18 @@ model JoinRequest {
} }
model TourParticipant { model TourParticipant {
id String @id @default(uuid())
tourId String tourId String
userId String userId String?
role ParticipantRole @default(MEMBER) role ParticipantRole @default(MEMBER)
displayName String?
adultCount Int @default(1)
childCount Int @default(0)
tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade) tour Tour @relation(fields: [tourId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([tourId, userId]) @@unique([tourId, userId])
} }
model Leg { model Leg {
@@ -162,6 +166,8 @@ model Location {
expenses Expense[] expenses Expense[]
photos Photo[] photos Photo[]
comments Comment[] comments Comment[]
createdAt DateTime @default(now())
} }
model Expense { model Expense {
+79 -10
View File
@@ -498,11 +498,14 @@ class PublicTourController {
expenses: { expenses: {
include: { include: {
location: { select: { name: true, plannedStart: true } }, location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } } paidBy: { select: { id: true, name: true } }
} }
}, },
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
}, },
}, },
@@ -896,7 +899,10 @@ class TourController {
orderBy: { sequence: 'asc' }, orderBy: { sequence: 'asc' },
include: { include: {
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
} }
} }
@@ -933,11 +939,14 @@ class TourController {
expenses: { expenses: {
include: { include: {
location: { select: { name: true, plannedStart: true } }, location: { select: { name: true, plannedStart: true } },
paidBy: { select: { name: true } } paidBy: { select: { id: true, name: true } }
} }
}, },
locations: { locations: {
orderBy: { plannedStart: 'asc' }, orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
],
include: { _count: { select: { comments: true } } } include: { _count: { select: { comments: true } } }
}, },
}, },
@@ -1003,6 +1012,58 @@ class TourController {
}); });
} }
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Patch(':tourId/members/:userId')
async updateMemberCounts(
@Param('tourId', ParseUUIDPipe) tourId: string,
@Param('userId', ParseUUIDPipe) userId: string,
@Body() body: { adultCount?: number; childCount?: number; role?: string },
@Req() req: any
) {
const isSelf = req.user.id === userId;
// Tìm quyền hạn của người gửi yêu cầu trong tour này
const requesterParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: req.user.id } },
});
const canManage = requesterParticipation?.role === 'OWNER' || requesterParticipation?.role === 'MANAGER';
if (!canManage && !isSelf) {
throw new ForbiddenException('Bạn không có quyền chỉnh sửa thông tin thành viên này.');
}
const data: any = {};
if (body.adultCount !== undefined) {
if (body.adultCount < 1) throw new BadRequestException('Số lượng người lớn tối thiểu là 1.');
data.adultCount = Number(body.adultCount);
}
if (body.childCount !== undefined) {
if (body.childCount < 0) throw new BadRequestException('Số lượng trẻ em không được âm.');
data.childCount = Number(body.childCount);
}
if (body.role !== undefined && canManage) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
if (validRoles.includes(body.role as any)) {
data.role = body.role;
}
}
// Xóa cache liên quan
await Promise.all([
this.cacheManager.del(`user-role:${userId}:${tourId}`),
this.cacheManager.del(tourId),
this.cacheManager.del(`/api/v1/tours/${tourId}`),
this.cacheManager.del(`/api/v1/tours/${tourId}/public`),
]);
return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId } },
data,
include: { user: { select: { id: true, name: true, email: true } } },
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests @Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
@UseGuards(JwtAuthGuard, TourRoleGuard) @UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':tourId/join-requests') @Get(':tourId/join-requests')
@@ -1449,7 +1510,10 @@ class RoutingController {
tourId: currentLeg.tourId, tourId: currentLeg.tourId,
sequence: currentLeg.sequence - 1 sequence: currentLeg.sequence - 1
}, },
include: { locations: { orderBy: { plannedStart: 'asc' } } } include: { locations: { orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
] } }
}); });
if (prevLeg?.locations?.length) { if (prevLeg?.locations?.length) {
@@ -1526,7 +1590,10 @@ class RoutingController {
const updatedLocations = await this.prisma.location.findMany({ const updatedLocations = await this.prisma.location.findMany({
where: { legId }, where: { legId },
orderBy: { plannedStart: 'asc' } orderBy: [
{ plannedStart: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
]
}); });
if (req.tourId) { if (req.tourId) {
@@ -1779,18 +1846,20 @@ class UserController {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
@Get() @Get()
@UseGuards(AdminGuard)
async getAllUsers(@Req() req: any, @Query('q') q?: string) { async getAllUsers(@Req() req: any, @Query('q') q?: string) {
const currentUserId = req.user?.id; const currentUserId = req.user?.id;
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: q where: {
isAnonymous: false,
...(q
? { ? {
OR: [ OR: [
{ name: { contains: q, mode: 'insensitive' as any } }, { name: { contains: q, mode: 'insensitive' as any } },
{ email: { contains: q, mode: 'insensitive' as any } }, { email: { contains: q, mode: 'insensitive' as any } },
], ],
} }
: undefined, : {}),
},
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true } select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true, phone: true, address: true }
}); });
return users.filter((u: any) => u.id !== currentUserId); return users.filter((u: any) => u.id !== currentUserId);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>Travel Planner</title> <title>Travel Planner</title>
<script type="module" crossorigin src="/assets/index-CtrQmjY1.js"></script> <script type="module" crossorigin src="/assets/index-C3QeRTiI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i0kJVU1C.css"> <link rel="stylesheet" crossorigin href="/assets/index-_0oFafPI.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+19 -5
View File
@@ -132,13 +132,14 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
[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) // 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 trước/hiện tại làm tham chiếu tiếp nối)
useEffect(() => { useEffect(() => {
if (isOpen && !editingLocation && formData.legId && !formData.name) { if (isOpen && !editingLocation && formData.legId && !formData.name) {
const selectedLeg = legs.find(l => l.id === formData.legId); const selectedLeg = legs.find(l => l.id === formData.legId);
if (selectedLeg && selectedLeg.locations && selectedLeg.locations.length > 0) { if (selectedLeg) {
// 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 if (selectedLeg.locations && selectedLeg.locations.length > 0) {
// Di chuyển đến địa điểm cuối cùng của chặng hiện tại để người dùng thấy điểm nối tiếp
const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1]; const lastLoc = selectedLeg.locations[selectedLeg.locations.length - 1];
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -146,10 +147,23 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
longitude: lastLoc.longitude longitude: lastLoc.longitude
})); }));
} else { } 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 // Chặng trống -> Lấy địa điểm cuối của chặng trước làm tọa độ tiếp nối
const currentLegIdx = legs.findIndex(l => l.id === formData.legId);
const prevLeg = currentLegIdx > 0 ? legs[currentLegIdx - 1] : null;
if (prevLeg && prevLeg.locations && prevLeg.locations.length > 0) {
const lastLoc = prevLeg.locations[prevLeg.locations.length - 1];
setFormData(prev => ({
...prev,
latitude: lastLoc.latitude,
longitude: lastLoc.longitude
}));
} else {
// Nếu không có chặng trước hoặc chặng trước trống, 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] })); setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
} }
} }
}
}
}, [formData.legId, isOpen, editingLocation, legs, mapCenter]); }, [formData.legId, isOpen, editingLocation, legs, mapCenter]);
// 2. Thực hiện các tính toán và hàm xử lý // 2. Thực hiện các tính toán và hàm xử lý
@@ -484,7 +498,7 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editin
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm" <select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}> value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
<option value="">-- Chọn người thanh toán --</option> <option value="">-- Chọn người thanh toán --</option>
{currentTour?.participants?.map((p: any) => { {currentTour?.participants?.filter((p: any) => p.user)?.map((p: any) => {
const name = p.user?.name; const name = p.user?.name;
const email = p.user?.email; const email = p.user?.email;
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' '); const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
+28 -4
View File
@@ -15,7 +15,7 @@ interface AddMemberModalProps {
isPublicView?: boolean; // New prop to indicate public view isPublicView?: boolean; // New prop to indicate public view
} }
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => { export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole, isPublicView }) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -54,8 +54,12 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const delayDebounceFn = setTimeout(() => {
fetchUsers(); fetchUsers();
}, [isOpen]); }, 300);
return () => clearTimeout(delayDebounceFn);
}, [query, isOpen]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -158,9 +162,9 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<div> <div>
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p> <p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.filter(p => p.user).length})</p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{participants.map((p) => { {participants.filter(p => p.user).map((p) => {
const rawToken = localStorage.getItem('token'); const rawToken = localStorage.getItem('token');
let currentUserId: string | null = null; let currentUserId: string | null = null;
try { try {
@@ -255,6 +259,26 @@ export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose,
)} )}
<div className="space-y-2"> <div className="space-y-2">
<div className="relative mb-2">
<input
type="text"
placeholder="Tìm kiếm theo tên hoặc địa chỉ email..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-10 pr-10 py-3 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-gray-800"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-blue-500" />
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-200 rounded-full text-gray-400 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{fetchError && ( {fetchError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100"> <div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
{fetchError} {fetchError}
File diff suppressed because one or more lines are too long
@@ -250,7 +250,7 @@ export const ItineraryTimeline = ({
{canEdit && ( {canEdit && (
<> <>
<button <button
onClick={() => onAddLocation?.(leg.id)} onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all" className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Thêm địa điểm vào chặng này" title="Thêm địa điểm vào chặng này"
> >
+7 -2
View File
@@ -402,7 +402,7 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<a <a
href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`} href={`${window.location.origin}/api/v1/public-photos/${photo.id}/share`}
className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center" className="w-full h-auto max-h-[85vh] object-contain cursor-zoom-in md:h-full md:max-h-none flex items-center justify-center relative"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setIsFullscreen(true); setIsFullscreen(true);
@@ -411,8 +411,13 @@ export const PublicPhotoModal: React.FC<PublicPhotoModalProps> = ({
<img <img
src={photo.imageUrl} src={photo.imageUrl}
alt="Public Map Upload" alt="Public Map Upload"
className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none" className="w-full h-auto max-h-[85vh] object-contain md:h-full md:max-h-none select-none"
draggable={false}
/> />
{/* Shield overlay to prevent Save Image As on right-click / long press for non-owners */}
{!isAuthorized && (
<div className="absolute inset-0 bg-transparent select-none z-10" />
)}
</a> </a>
</div> </div>
+4 -2
View File
@@ -546,8 +546,9 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
className: 'custom-photo-bubble', className: 'custom-photo-bubble',
html: ` html: `
<a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block"> <a href="${window.location.origin}/api/v1/public-photos/${latestPhoto.id}/share" onclick="event.preventDefault();" class="relative group block">
<div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110"> <div class="w-12 h-12 rounded-full border-4 border-emerald-500 shadow-lg overflow-hidden transition-transform group-hover:scale-110 relative">
<img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover" /> <img src="${latestPhoto.imageUrl}" class="w-full h-full object-cover select-none" draggable="false" />
<div class="absolute inset-0 bg-transparent select-none z-10"></div>
</div> </div>
<div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white"> <div class="absolute -bottom-1 -right-1 bg-emerald-600 rounded-full w-5 h-5 border-2 border-white flex items-center justify-center text-[10px] font-black text-white">
📸 📸
@@ -604,6 +605,7 @@ export const ExploreMap = ({ onBack, onLogout, user, onViewTour, onOpenMyPhotos,
const newTourNote = { const newTourNote = {
id: Date.now().toString(), id: Date.now().toString(),
tourId: tour.id,
title: `Ghi chú của hành trình: ${tour.title}`, title: `Ghi chú của hành trình: ${tour.title}`,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`, content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${tour.title}</strong> của bạn tại đây...</p>`,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
+115 -8
View File
@@ -701,6 +701,22 @@ export const TourDetailPage = ({
const [commentLocationName, setCommentLocationName] = useState(''); const [commentLocationName, setCommentLocationName] = useState('');
const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null); const [joinRequestActionId, setJoinRequestActionId] = useState<string | null>(null);
// State cho Modal ghi chú nhanh
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
const [quickNoteInput, setQuickNoteInput] = useState('');
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
useEffect(() => {
if (currentTour) {
setTitleInput(currentTour.title ?? '');
setDescriptionInput(currentTour.description ?? '');
setAdultCountInput(currentTour.adultCount ?? 0);
setChildCountInput(currentTour.childCount ?? 0);
setChildDiscountInput(currentTour.childDiscount ?? 0);
setTagsInput(currentTour.tags ?? []);
}
}, [currentTour]);
// State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình // State tìm kiếm cho chế độ Bản đồ trong Tab Lộ trình
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]); const [searchResults, setSearchResults] = useState<any[]>([]);
@@ -1201,6 +1217,35 @@ export const TourDetailPage = ({
} }
}; };
// Đồng bộ tiêu đề ghi chú khi đổi tên chuyến đi
const syncTourNoteTitle = (tourId: string, newTitle: string) => {
const savedNotes = localStorage.getItem('my_journey_notes');
if (!savedNotes) return;
try {
let notes = JSON.parse(savedNotes);
if (Array.isArray(notes)) {
let updated = false;
const oldTitle = currentTour?.title || '';
notes = notes.map((n: any) => {
if (n.tourId === tourId || n.title === `Ghi chú của hành trình: ${oldTitle}`) {
n.tourId = tourId;
n.title = `Ghi chú của hành trình: ${newTitle}`;
if (oldTitle && n.content) {
n.content = n.content.split(`<strong>${oldTitle}</strong>`).join(`<strong>${newTitle}</strong>`);
}
updated = true;
}
return n;
});
if (updated) {
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
}
}
} catch (e) {
console.error("Error syncing tour note title:", e);
}
};
// Hàm xử lý cập nhật số lượng người tham gia // Hàm xử lý cập nhật số lượng người tham gia
const handleUpdateTourInfo = async () => { const handleUpdateTourInfo = async () => {
if (!currentTour) return; if (!currentTour) return;
@@ -1218,31 +1263,40 @@ export const TourDetailPage = ({
message: 'Đã cập nhật thông tin chuyến đi.', message: 'Đã cập nhật thông tin chuyến đi.',
type: 'success' type: 'success'
}); });
if (titleInput !== currentTour.title) {
syncTourNoteTitle(currentTour.id, titleInput);
}
fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan fetchTour(currentTour.id); // Re-fetch tour để cập nhật lại các tính toán liên quan
} catch (error: any) { } catch (error: any) {
notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' }); notify({ title: 'Lỗi', message: error.message || 'Không thể cập nhật thông tin.', type: 'error' });
} }
}; };
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour // Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => { const handleQuickNote = (locationName: string) => {
if (isPublicView) return; if (isPublicView) return;
setQuickNoteLocName(locationName);
setQuickNoteInput('');
};
const content = window.prompt(`Ghi chú nhanh cho địa điểm: ${locationName}`); // Hàm xử lý submit ghi chú nhanh từ modal
if (!content || !content.trim()) return; const submitQuickNote = () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
const content = quickNoteInput;
const storedUser = localStorage.getItem('user'); const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' }; const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên'; const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN'); const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour?.title}`; const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
const savedNotes = localStorage.getItem('my_journey_notes'); const savedNotes = localStorage.getItem('my_journey_notes');
let notes = []; let notes = [];
try { try {
notes = savedNotes ? JSON.parse(savedNotes) : []; notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; } } catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.title === noteTitle); let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp // Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = ` const newContentLine = `
@@ -1250,18 +1304,21 @@ export const TourDetailPage = ({
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} 👤 ${userName}</span> <span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} 👤 ${userName}</span>
</div> </div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${locationName}:</strong> ${content}</p> <p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
</div> </div>
<p></p> <p></p>
`; `;
if (targetNote) { if (targetNote) {
targetNote.tourId = currentTour.id;
targetNote.title = noteTitle;
targetNote.content += newContentLine; targetNote.content += newContentLine;
} else { } else {
const newNote = { const newNote = {
id: Date.now().toString(), id: Date.now().toString(),
tourId: currentTour.id,
title: noteTitle, title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour?.title}</strong> của bạn tại đây...</p>` + newContentLine, content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}; };
notes.unshift(newNote); notes.unshift(newNote);
@@ -1269,6 +1326,8 @@ export const TourDetailPage = ({
localStorage.setItem('my_journey_notes', JSON.stringify(notes)); localStorage.setItem('my_journey_notes', JSON.stringify(notes));
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' }); notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
setQuickNoteLocName(null);
setQuickNoteInput('');
}; };
// Hàm xử lý xóa Tour vĩnh viễn // Hàm xử lý xóa Tour vĩnh viễn
@@ -1352,7 +1411,7 @@ export const TourDetailPage = ({
const tourInfo = { const tourInfo = {
title: currentTour?.title || "Hành trình khám phá TP.HCM", title: currentTour?.title || "Hành trình khám phá TP.HCM",
date: tourDateDisplay, date: tourDateDisplay,
membersCount: currentTour?.participants?.length || 0, membersCount: currentTour?.participants?.filter((p: any) => p.user)?.length || 0,
budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND", budget: currentTour?.totalCost ? `${Number(currentTour.totalCost).toLocaleString()} VND` : "0 VND",
coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop" coverImage: getMostLikedPhoto(currentTour?.photos || [])?.imageUrl || "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?q=80&w=2070&auto=format&fit=crop"
}; };
@@ -1448,7 +1507,7 @@ export const TourDetailPage = ({
{/* Member Avatars Stack */} {/* Member Avatars Stack */}
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<div className="flex flex-wrap gap-2"> {/* Always show participants */} <div className="flex flex-wrap gap-2"> {/* Always show participants */}
{currentTour?.participants?.slice(0, 5).map((p: any, i: number) => ( {currentTour?.participants?.filter((p: any) => p.user)?.slice(0, 5).map((p: any, i: number) => (
<button <button
key={p.userId || i} key={p.userId || i}
onClick={() => { onClick={() => {
@@ -2682,6 +2741,54 @@ export const TourDetailPage = ({
/> />
)} )}
{/* Quick Note Modal */}
{quickNoteLocName && (
<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" onClick={() => setQuickNoteLocName(null)} />
<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">Ghi chú nhanh</h3>
<button onClick={() => setQuickNoteLocName(null)} 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-4">
<p className="text-sm font-bold text-gray-700">
📍 Đa điểm: <span className="text-blue-600">{quickNoteLocName}</span>
</p>
<div>
<label className="block text-xs font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Nội dung ghi chú</label>
<textarea
value={quickNoteInput}
onChange={(e) => setQuickNoteInput(e.target.value)}
placeholder="Nhập nội dung ghi chú nhanh..."
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"
autoFocus
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-8">
<button
onClick={() => setQuickNoteLocName(null)}
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={submitQuickNote}
disabled={!quickNoteInput.trim()}
className="py-4 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none text-white font-bold rounded-2xl shadow-lg shadow-blue-100 transition-all active:scale-95"
>
Lưu
</button>
</div>
</div>
</div>
)}
{/* Member Detail Popover */} {/* Member Detail Popover */}
{isMemberDetailOpen && selectedMember && ( {isMemberDetailOpen && selectedMember && (
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
+17
View File
@@ -25,6 +25,7 @@ interface TourState {
optimizeRouting: (legId: string) => Promise<void>; optimizeRouting: (legId: string) => Promise<void>;
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<any>; addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<any>;
removeMember: (tourId: string, userId: string) => Promise<void>; removeMember: (tourId: string, userId: string) => Promise<void>;
updateMemberFamilyCount: (tourId: string, userId: string, adultCount: number, childCount: number) => Promise<void>;
setActiveLegId: (id: string | null) => void; setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void; setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>; fetchTour: (id: string) => Promise<void>;
@@ -329,6 +330,22 @@ export const useTourStore = create<TourState>((set, get) => ({
const { currentTour } = get(); const { currentTour } = get();
if (currentTour) get().fetchTour(currentTour.id); if (currentTour) get().fetchTour(currentTour.id);
}, },
updateMemberFamilyCount: async (tourId: string, userId: string, adultCount: number, childCount: number) => {
const response = await fetch(`/api/v1/tours/${tourId}/members/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({ adultCount, childCount }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || data.error || 'Lỗi khi cập nhật thành viên');
}
const { currentTour } = get();
if (currentTour) get().fetchTour(currentTour.id);
},
createJoinRequest: async (tourId: string, userId?: string) => { createJoinRequest: async (tourId: string, userId?: string) => {
const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, { const response = await fetch(`/api/v1/tours/${tourId}/join-requests`, {
method: 'POST', method: 'POST',