Thêm tính năng xóa thành viên ra khỏi tour
This commit is contained in:
Vendored
+10
@@ -3,6 +3,16 @@ interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
participants?: Array<{
|
||||
userId: string;
|
||||
role: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
onRemoveMember?: (userId: string) => Promise<void>;
|
||||
}
|
||||
export declare const AddMemberModal: React.FC<AddMemberModalProps>;
|
||||
export {};
|
||||
|
||||
Vendored
+20
-6
@@ -1,7 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, UserPlus, Loader2, Shield } from 'lucide-react';
|
||||
export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
export const AddMemberModal = ({ isOpen, onClose, tourId, participants = [], onRemoveMember }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -10,6 +10,8 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setFetchError('');
|
||||
@@ -21,7 +23,7 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
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);
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
}
|
||||
catch (err) {
|
||||
setFetchError(err.message || 'Không thể tải danh sách người dùng');
|
||||
@@ -41,12 +43,24 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
setSelectedUser(null);
|
||||
setRole('MEMBER');
|
||||
setFetchError('');
|
||||
setSubmitError('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
const handleRemove = async (userId, memberName) => {
|
||||
if (!window.confirm(`Xóa ${memberName} khỏi tour này?`) || !onRemoveMember)
|
||||
return;
|
||||
try {
|
||||
await onRemoveMember(userId);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message || 'Không thể xóa thành viên');
|
||||
}
|
||||
};
|
||||
const handleAdd = async () => {
|
||||
if (!selectedUser)
|
||||
return;
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||
@@ -64,7 +78,7 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
onClose();
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
setSubmitError(err.message || 'Thêm thành viên thất bại');
|
||||
}
|
||||
finally {
|
||||
setSubmitting(false);
|
||||
@@ -72,9 +86,9 @@ export const AddMemberModal = ({ isOpen, onClose, tourId }) => {
|
||||
};
|
||||
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) => {
|
||||
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", { children: [_jsxs("p", { className: "text-[11px] font-bold text-gray-500 mb-2", children: ["\u0110\u00E3 g\u00E1n (", participants.length, ")"] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [participants.map((p) => (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-gray-100 border border-gray-200 px-2.5 py-1 text-[11px] font-semibold text-gray-700", children: [p.user?.name || p.userId, onRemoveMember && (_jsx("button", { onClick: () => handleRemove(p.userId, p.user?.name || p.userId), className: "leading-none text-gray-400 hover:text-red-500", children: "-" }))] }, p.userId))), participants.length === 0 && (_jsx("span", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 th\u00E0nh vi\u00EAn n\u00E0o" }))] })] }), _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 })), submitError && (_jsx("div", { className: "p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100", children: submitError })), 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: [visibleUsers.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' })] })] })] }));
|
||||
}), !loading && visibleUsers.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
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -109,7 +109,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember } = useTourStore();
|
||||
const { currentTour, legs, fetchTour, fetchPublicTours, publicTours, userRole, mapCenter, setMapCenter, updateTourStartPoint, updateTourEndPoint, initializeLegs, addLocation, addMember, removeMember } = useTourStore();
|
||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||
const [mapZoom] = useState(initialViewState?.zoom || 13);
|
||||
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
||||
@@ -264,6 +264,6 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan')
|
||||
setIsAddLocationOpen(true);
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id }))] }));
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) })), currentTour && (_jsx(AddMemberModal, { isOpen: isAddMemberOpen, onClose: () => setIsAddMemberOpen(false), tourId: currentTour.id, participants: currentTour.participants || [], onRemoveMember: (userId) => removeMember(currentTour.id, userId) })), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id }))] }));
|
||||
};
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+21
@@ -346,6 +346,18 @@ let TourController = class TourController {
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
}
|
||||
async removeMember(tourId, userId) {
|
||||
const participation = await this.prisma.tourParticipant.findUnique({
|
||||
where: { tourId_userId: { tourId, userId } },
|
||||
});
|
||||
if (!participation) {
|
||||
throw new NotFoundException('Thành viên này không có trong tour');
|
||||
}
|
||||
await this.prisma.tourParticipant.delete({
|
||||
where: { tourId_userId: { tourId, userId } },
|
||||
});
|
||||
return { message: 'Đã xóa thành viên khỏi tour' };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard),
|
||||
@@ -447,6 +459,15 @@ __decorate([
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "addMember", null);
|
||||
__decorate([
|
||||
UseGuards(JwtAuthGuard, TourRoleGuard),
|
||||
Delete(':tourId/members/:userId'),
|
||||
__param(0, Param('tourId', ParseUUIDPipe)),
|
||||
__param(1, Param('userId', ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], TourController.prototype, "removeMember", null);
|
||||
TourController = __decorate([
|
||||
Controller('v1/tours'),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
|
||||
Vendored
+1
-1
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
+1
@@ -24,6 +24,7 @@ interface TourState {
|
||||
userId: string;
|
||||
role?: string;
|
||||
}) => Promise<void>;
|
||||
removeMember: (tourId: string, userId: string) => Promise<void>;
|
||||
setActiveLegId: (id: string | null) => void;
|
||||
setMapCenter: (pos: [number, number]) => void;
|
||||
fetchTour: (id: string) => Promise<void>;
|
||||
|
||||
Vendored
+14
@@ -242,6 +242,20 @@ export const useTourStore = create((set, get) => ({
|
||||
set({ currentTour: { ...currentTour, legs: updatedLegs }, legs: updatedLegs });
|
||||
}
|
||||
},
|
||||
removeMember: async (tourId, userId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Lỗi khi xóa thành viên');
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
},
|
||||
addMember: async (tourId, member) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/members`, {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user