Sửa lỗi nhấn + để thêm thành viên của tour
This commit is contained in:
@@ -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 và 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
@@ -3,6 +3,7 @@ import { ItineraryTimeline } from './ItineraryTimeline.js';
|
|||||||
import { ExpenseManager } from './ExpenseManager.js';
|
import { ExpenseManager } from './ExpenseManager.js';
|
||||||
import { useTourStore } from './useTourStore.js';
|
import { useTourStore } from './useTourStore.js';
|
||||||
import { AddLocationModal } from './AddLocationModal.js';
|
import { AddLocationModal } from './AddLocationModal.js';
|
||||||
|
import { AddMemberModal } from './AddMemberModal.js';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup, useMapEvents, Polyline } from 'react-leaflet';
|
||||||
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
import _MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
const MarkerClusterGroup = (_MarkerClusterGroup as any).default || _MarkerClusterGroup;
|
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 [activeTab, setActiveTab] = useState<'plan' | 'expense' | 'photo' | 'settings'>('plan');
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||||
|
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
|
||||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||||
|
|
||||||
@@ -177,7 +179,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
const {
|
const {
|
||||||
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
currentTour, legs, fetchTour, fetchPublicTours, publicTours,
|
||||||
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
userRole, mapCenter, setMapCenter, updateTourStartPoint,
|
||||||
updateTourEndPoint, initializeLegs, addLocation
|
updateTourEndPoint, initializeLegs, addLocation, addMember
|
||||||
} = useTourStore();
|
} = useTourStore();
|
||||||
|
|
||||||
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
const canEdit = ['OWNER', 'MANAGER'].includes(userRole || '');
|
||||||
@@ -401,7 +403,16 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,6 +617,15 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
{currentTour && (
|
||||||
|
<AddMemberModal
|
||||||
|
isOpen={isAddMemberOpen}
|
||||||
|
onClose={() => setIsAddMemberOpen(false)}
|
||||||
|
tourId={currentTour.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add Location Modal */}
|
{/* Add Location Modal */}
|
||||||
{currentTour && (
|
{currentTour && (
|
||||||
<AddLocationModal
|
<AddLocationModal
|
||||||
|
|||||||
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 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
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 { PrismaService } from './prisma.service.js';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
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}`);
|
throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||||
return tour;
|
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([
|
__decorate([
|
||||||
UseGuards(JwtAuthGuard),
|
UseGuards(JwtAuthGuard),
|
||||||
@@ -416,6 +438,16 @@ __decorate([
|
|||||||
__metadata("design:paramtypes", [String]),
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], TourController.prototype, "getTourDetails", null);
|
], 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([
|
TourController = __decorate([
|
||||||
Controller('v1/tours'),
|
Controller('v1/tours'),
|
||||||
__metadata("design:paramtypes", [PrismaService])
|
__metadata("design:paramtypes", [PrismaService])
|
||||||
@@ -639,9 +671,17 @@ let UserController = class UserController {
|
|||||||
constructor(prisma) {
|
constructor(prisma) {
|
||||||
this.prisma = prisma;
|
this.prisma = prisma;
|
||||||
}
|
}
|
||||||
async getAllUsers() {
|
async getAllUsers(q) {
|
||||||
return this.prisma.user.findMany({
|
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) {
|
async updateUser(id, data) {
|
||||||
@@ -677,8 +717,9 @@ let UserController = class UserController {
|
|||||||
};
|
};
|
||||||
__decorate([
|
__decorate([
|
||||||
Get(),
|
Get(),
|
||||||
|
__param(0, Query('q')),
|
||||||
__metadata("design:type", Function),
|
__metadata("design:type", Function),
|
||||||
__metadata("design:paramtypes", []),
|
__metadata("design:paramtypes", [String]),
|
||||||
__metadata("design:returntype", Promise)
|
__metadata("design:returntype", Promise)
|
||||||
], UserController.prototype, "getAllUsers", null);
|
], UserController.prototype, "getAllUsers", null);
|
||||||
__decorate([
|
__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 { OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import 'dotenv/config';
|
|
||||||
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export declare class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool;
|
|
||||||
constructor();
|
constructor();
|
||||||
onModuleInit(): Promise<void>;
|
onModuleInit(): Promise<void>;
|
||||||
onModuleDestroy(): 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 { Injectable } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import 'dotenv/config';
|
|
||||||
let PrismaService = class PrismaService extends PrismaClient {
|
let PrismaService = class PrismaService extends PrismaClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
super();
|
||||||
const adapter = new PrismaPg(pool);
|
|
||||||
super({ adapter });
|
|
||||||
this.pool = pool;
|
|
||||||
}
|
}
|
||||||
async onModuleInit() { await this.$connect(); }
|
async onModuleInit() { await this.$connect(); }
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
await this.$disconnect();
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
PrismaService = __decorate([
|
PrismaService = __decorate([
|
||||||
Injectable(),
|
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>;
|
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
optimizeRouting: (legId: string) => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
|
addMember: (tourId: string, member: {
|
||||||
|
userId: string;
|
||||||
|
role?: string;
|
||||||
|
}) => 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>;
|
||||||
|
|||||||
Vendored
+24
-6
@@ -13,9 +13,12 @@ export const useTourStore = create((set, get) => ({
|
|||||||
fetchTour: async (id) => {
|
fetchTour: async (id) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
let role = 'VIEWER_ONLY';
|
let role = 'VIEWER_ONLY';
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -31,12 +34,11 @@ export const useTourStore = create((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const legs = data.legs || [];
|
const legs = data.legs || [];
|
||||||
set({
|
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
|
||||||
currentTour: data,
|
}
|
||||||
legs: legs,
|
catch (err) {
|
||||||
userRole: role,
|
console.error('Không thể tải tour:', err);
|
||||||
activeLegId: legs.length > 0 ? legs[0].id : null
|
}
|
||||||
});
|
|
||||||
},
|
},
|
||||||
fetchPublicTours: async () => {
|
fetchPublicTours: async () => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
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 });
|
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
|
//# sourceMappingURL=useTourStore.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
# docs/help/
|
||||||
|
Help snippet index for this project.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
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 { PrismaService } from './prisma.service.js';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as bcrypt from 'bcrypt';
|
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}`);
|
if (!tour) throw new NotFoundException(`Không tìm thấy Tour với ID ${id}`);
|
||||||
return tour;
|
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')
|
@Controller('v1/locations')
|
||||||
@@ -577,9 +603,17 @@ class UserController {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getAllUsers() {
|
async getAllUsers(@Query('q') q?: string) {
|
||||||
return this.prisma.user.findMany({
|
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
@@ -1,23 +1,12 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import 'dotenv/config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool: Pool;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
super();
|
||||||
const adapter = new PrismaPg(pool);
|
|
||||||
super({ adapter });
|
|
||||||
this.pool = pool;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() { await this.$connect(); }
|
async onModuleInit() { await this.$connect(); }
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() { await this.$disconnect(); }
|
||||||
await this.$disconnect();
|
|
||||||
await this.pool.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
@@ -22,6 +22,7 @@ interface TourState {
|
|||||||
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||||
optimizeRouting: (legId: string) => Promise<void>;
|
optimizeRouting: (legId: string) => Promise<void>;
|
||||||
|
addMember: (tourId: string, member: { userId: string; role?: string }) => 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>;
|
||||||
@@ -42,12 +43,13 @@ export const useTourStore = create<TourState>((set, get) => ({
|
|||||||
fetchTour: async (id: string) => {
|
fetchTour: async (id: string) => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
const response = await fetch(`${API_BASE}/api/v1/tours/${id}`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
// ...existing role detection...
|
||||||
// Xác định role của người dùng hiện tại dựa trên thông tin trong JWT
|
|
||||||
let role = 'VIEWER_ONLY';
|
let role = 'VIEWER_ONLY';
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
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);
|
console.error("Lỗi khi xác định vai trò người dùng:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const legs = data.legs || [];
|
const legs = data.legs || [];
|
||||||
set({
|
set({ currentTour: data, legs, userRole: role, activeLegId: legs.length > 0 ? legs[0].id : null });
|
||||||
currentTour: data,
|
} catch (err: any) {
|
||||||
legs: legs,
|
console.error('Không thể tải tour:', err);
|
||||||
userRole: role,
|
// Không set null để tránh flash trắng; giữ nguyên state cũ nếu có
|
||||||
activeLegId: legs.length > 0 ? legs[0].id : null
|
}
|
||||||
});
|
|
||||||
},
|
},
|
||||||
fetchPublicTours: async () => {
|
fetchPublicTours: async () => {
|
||||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
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 });
|
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);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
Reference in New Issue
Block a user