Sửa lỗi nhấn + để thêm thành viên của tour

This commit is contained in:
2026-06-14 17:16:19 +07:00
parent c6d0fdd5d1
commit 81e715ddb3
24 changed files with 515 additions and 239 deletions
+184
View File
@@ -0,0 +1,184 @@
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>
);
};
+22 -2
View File
@@ -3,6 +3,7 @@ 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;
@@ -160,6 +161,7 @@ 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);
@@ -177,7 +179,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
const {
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
userRole, mapCenter, setMapCenter, updateTourStartPoint,
updateTourEndPoint, initializeLegs, addLocation
updateTourEndPoint, initializeLegs, addLocation, addMember
} = useTourStore();
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
@@ -401,7 +403,16 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
)}
</div>
<button className="p-2 bg-white/10 hover:bg-white/20 rounded-full border border-white/20 backdrop-blur-sm transition-all ml-2">
<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>
@@ -606,6 +617,15 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
</div>
)}
{/* Add Member Modal */}
{currentTour && (
<AddMemberModal
isOpen={isAddMemberOpen}
onClose={() => setIsAddMemberOpen(false)}
tourId={currentTour.id}
/>
)}
{/* Add Location Modal */}
{currentTour && (
<AddLocationModal
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
interface AddMemberModalProps {
isOpen: boolean;
onClose: () => void;
tourId: string;
}
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
export {};
+80
View File
@@ -0,0 +1,80 @@
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 }) => {
const [query, setQuery] = useState('');
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const [role, setRole] = useState('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) {
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) {
alert(err.message);
}
finally {
setSubmitting(false);
}
};
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) => {
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' })] })] })] }));
};
//# sourceMappingURL=AddMemberModal.js.map
+1
View File
File diff suppressed because one or more lines are too long
+10 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+45 -4
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 } from '@nestjs/common';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
@@ -325,6 +325,28 @@ let TourController = class TourController {
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
return tour;
}
async addMember(tourId, body, req) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'];
const role = validRoles.includes(body.role) ? body.role : 'MEMBER';
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } },
});
if (participation) {
return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId: body.userId } },
data: { role },
include: { user: { select: { id: true, name: true, email: true } } },
});
}
return this.prisma.tourParticipant.create({
data: {
tourId,
userId: body.userId,
role,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
}
};
__decorate([
UseGuards(JwtAuthGuard),
@@ -416,6 +438,16 @@ __decorate([
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], TourController.prototype, "getTourDetails", null);
__decorate([
UseGuards(JwtAuthGuard, TourRoleGuard),
Post(':tourId/members'),
__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, "addMember", null);
TourController = __decorate([
Controller('v1/tours'),
__metadata("design:paramtypes", [PrismaService])
@@ -639,9 +671,17 @@ let UserController = class UserController {
constructor(prisma) {
this.prisma = prisma;
}
async getAllUsers() {
async getAllUsers(q) {
return this.prisma.user.findMany({
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
where: q
? {
OR: [
{ name: { 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 }
});
}
async updateUser(id, data) {
@@ -677,8 +717,9 @@ let UserController = class UserController {
};
__decorate([
Get(),
__param(0, Query('q')),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:paramtypes", [String]),
__metadata("design:returntype", Promise)
], UserController.prototype, "getAllUsers", null);
__decorate([
+1 -1
View File
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,8 +1,6 @@
import { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import 'dotenv/config';
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private pool;
constructor();
onModuleInit(): Promise<void>;
onModuleDestroy(): Promise<void>;
+2 -11
View File
@@ -9,21 +9,12 @@ var __metadata = (this && this.__metadata) || function (k, v) {
};
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
let PrismaService = class PrismaService extends PrismaClient {
constructor() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
super({ adapter });
this.pool = pool;
super();
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
async onModuleDestroy() { await this.$disconnect(); }
};
PrismaService = __decorate([
Injectable(),
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../prisma.service.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAC1B,OAAO,eAAe,CAAC;AAGhB,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,YAAY;IAG7C;QACE,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,YAAY,KAAK,MAAM,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IAC/C,KAAK,CAAC,eAAe;QACnB,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QACzB,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IACxB,CAAC;CACF,CAAA;AAfY,aAAa;IADzB,UAAU,EAAE;;GACA,aAAa,CAezB"}
{"version":3,"file":"prisma.service.js","sourceRoot":"","sources":["../prisma.service.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,UAAU,EAAiC,MAAM,gBAAgB,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAGvC,IAAM,aAAa,GAAnB,MAAM,aAAc,SAAQ,YAAY;IAC7C;QACE,KAAK,EAAE,CAAC;IACV,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;AAPY,aAAa;IADzB,UAAU,EAAE;;GACA,aAAa,CAOzB"}
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -20,6 +20,10 @@ interface TourState {
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
optimizeRouting: (legId: string) => Promise<void>;
addMember: (tourId: string, member: {
userId: string;
role?: string;
}) => Promise<void>;
setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>;
+24 -6
View File
@@ -13,9 +13,12 @@ export const useTourStore = create((set, get) => ({
fetchTour: async (id) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token');
try {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok)
throw new Error(`HTTP ${response.status}`);
const data = await response.json();
let role = 'VIEWER_ONLY';
if (token) {
@@ -31,12 +34,11 @@ export const useTourStore = create((set, get) => ({
}
}
const legs = data.legs || [];
set({
currentTour: data,
legs: legs,
userRole: role,
activeLegId: legs.length > 0 ? legs[0].id : null
});
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
}
catch (err) {
console.error('Không thể tải tour:', err);
}
},
fetchPublicTours: async () => {
const API_BASE = `http://${window.location.hostname}:3001`;
@@ -240,5 +242,21 @@ export const useTourStore = create((set, get) => ({
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
}
},
addMember: async (tourId, member) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(member),
});
if (!response.ok)
throw new Error('Lỗi khi thêm thành viên');
const { currentTour } = get();
if (currentTour)
get().fetchTour(currentTour.id);
},
}));
//# sourceMappingURL=useTourStore.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
# docs/help/
Help snippet index for this project.
+37 -3
View File
@@ -1,6 +1,6 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req } from '@nestjs/common';
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
import 'dotenv/config';
import * as bcrypt from 'bcrypt';
@@ -355,6 +355,32 @@ class TourController {
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
return tour;
}
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members')
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) {
const validRoles = ['OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY'] as const;
const role = validRoles.includes(body.role as any) ? body.role as 'OWNER' | 'MANAGER' | 'MEMBER' | 'MEMBER_NO_FINANCE' | 'VIEWER_ONLY' : 'MEMBER';
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: body.userId } },
});
if (participation) {
return this.prisma.tourParticipant.update({
where: { tourId_userId: { tourId, userId: body.userId } },
data: { role },
include: { user: { select: { id: true, name: true, email: true } } },
});
}
return this.prisma.tourParticipant.create({
data: {
tourId,
userId: body.userId,
role,
},
include: { user: { select: { id: true, name: true, email: true } } },
});
}
}
@Controller('v1/locations')
@@ -577,9 +603,17 @@ class UserController {
constructor(private prisma: PrismaService) {}
@Get()
async getAllUsers() {
async getAllUsers(@Query('q') q?: string) {
return this.prisma.user.findMany({
select: { id: true, email: true, name: true, isAdmin: true, isBlocked: true, createdAt: true }
where: q
? {
OR: [
{ name: { 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 }
});
}
+2 -13
View File
@@ -1,23 +1,12 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import 'dotenv/config';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private pool: Pool;
constructor() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
super({ adapter });
this.pool = pool;
super();
}
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() {
await this.$disconnect();
await this.pool.end();
}
async onModuleDestroy() { await this.$disconnect(); }
}
@@ -1,142 +0,0 @@
-- CreateEnum
CREATE TYPE "ParticipantRole" AS ENUM ('OWNER', 'MANAGER', 'MEMBER', 'MEMBER_NO_FINANCE', 'VIEWER_ONLY');
-- 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,
"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 "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");
-- AddForeignKey
ALTER TABLE "Tour" ADD CONSTRAINT "Tour_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT 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;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "address" TEXT,
ADD COLUMN "phone" TEXT;
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+33
View File
@@ -0,0 +1,33 @@
import { PrismaClient } from './prisma/client.js';
import bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
const email = 'owner@travel.com';
const newPassword = 'admin123';
const hash = await bcrypt.hash(newPassword, 10);
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
console.log('Tạo tài khoản admin mới...');
await prisma.user.create({
data: {
email,
passwordHash: hash,
name: 'Admin',
isAdmin: true,
},
});
} else {
await prisma.user.update({
where: { email },
data: { passwordHash: hash, isAdmin: true },
});
}
console.log(`Đã cập nhật xong mật khẩu cho ${email}`);
await prisma.$disconnect();
}
main();
+23 -9
View File
@@ -22,6 +22,7 @@ interface TourState {
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
optimizeRouting: (legId: string) => Promise<void>;
addMember: (tourId: string, member: { userId: string; role?: string }) => Promise<void>;
setActiveLegId: (id: string | null) => void;
setMapCenter: (pos: [number, number]) => void;
fetchTour: (id: string) => Promise<void>;
@@ -42,12 +43,13 @@ export const useTourStore = create<TourState>((set, get) => ({
fetchTour: async (id: string) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const token = localStorage.getItem('token');
try {
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
// Xác định role của người dùng hiện tại dựa trên thông tin trong JWT
// ...existing role detection...
let role = 'VIEWER_ONLY';
if (token) {
try {
@@ -59,14 +61,12 @@ export const useTourStore = create<TourState>((set, get) => ({
console.error("Lỗi khi xác định vai trò người dùng:", e);
}
}
const legs = data.legs || [];
set({
currentTour: data,
legs: legs,
userRole: role,
activeLegId: legs.length > 0 ? legs[0].id : null
});
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
} catch (err: any) {
console.error('Không thể tải tour:', err);
// Không set null để tránh flash trắng; giữ nguyên state cũ nếu có
}
},
fetchPublicTours: async () => {
const API_BASE = `http://${window.location.hostname}:3001`;
@@ -269,4 +269,18 @@ export const useTourStore = create<TourState>((set, get) => ({
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
}
},
addMember: async (tourId: string, member: { userId: string; role?: string }) => {
const API_BASE = `http://${window.location.hostname}:3001`;
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(member),
});
if (!response.ok) throw new Error('Lỗi khi thêm thành viên');
const { currentTour } = get();
if (currentTour) get().fetchTour(currentTour.id);
},
}));