Sửa lỗi nhấn + để thêm thành viên của tour
This commit is contained in:
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
}
|
||||
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||
export {};
|
||||
Vendored
+80
@@ -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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+10
-3
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+45
-4
@@ -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([
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
-2
@@ -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>;
|
||||
|
||||
Vendored
+2
-11
@@ -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(),
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -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>;
|
||||
|
||||
Vendored
+40
-22
@@ -13,30 +13,32 @@ export const useTourStore = create((set, get) => ({
|
||||
fetchTour: async (id) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
let role = 'VIEWER_ONLY';
|
||||
if (token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentUserId = payload.sub;
|
||||
const participant = data.participants?.find((p) => p.userId === currentUserId);
|
||||
if (participant)
|
||||
role = participant.role;
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||
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) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentUserId = payload.sub;
|
||||
const participant = data.participants?.find((p) => p.userId === currentUserId);
|
||||
if (participant)
|
||||
role = participant.role;
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||
}
|
||||
}
|
||||
const legs = data.legs || [];
|
||||
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);
|
||||
}
|
||||
const legs = data.legs || [];
|
||||
set({
|
||||
currentTour: data,
|
||||
legs: legs,
|
||||
userRole: role,
|
||||
activeLegId: legs.length > 0 ? legs[0].id : null
|
||||
});
|
||||
},
|
||||
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
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user