Sửa lỗi tái cấu trúc thư mục và khai báo import
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, MapPin, Loader2, Clock, Map as MapIcon } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore.js';
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
|
||||
// Cấu hình Icon mặc định để tránh crash Marker trong Modal
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
// Component Helper xử lý việc chọn vị trí trên mini map bằng chuột phải
|
||||
const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, center: [number, number] }) => {
|
||||
const [menuPos, setMenuPos] = useState<{ x: number, y: number, latlng: L.LatLng } | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const map = useMap();
|
||||
|
||||
useMapEvents({
|
||||
contextmenu: (e) => {
|
||||
L.DomEvent.preventDefault(e.originalEvent);
|
||||
L.DomEvent.stopPropagation(e.originalEvent);
|
||||
setMenuPos({ x: e.containerPoint.x, y: e.containerPoint.y, latlng: e.latlng });
|
||||
},
|
||||
click: () => setMenuPos(null),
|
||||
dragstart: () => setMenuPos(null),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (menuPos && menuRef.current) {
|
||||
L.DomEvent.disableClickPropagation(menuRef.current);
|
||||
}
|
||||
}, [menuPos]);
|
||||
|
||||
useEffect(() => {
|
||||
map.setView(center, map.getZoom());
|
||||
}, [center]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuPos && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-[3000] bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-44 animate-in zoom-in-95 duration-200"
|
||||
style={{ top: menuPos.y, left: menuPos.x }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onPick(menuPos.latlng); setMenuPos(null); }}
|
||||
className="w-full text-left px-4 py-2 hover:bg-blue-50 text-xs font-bold text-blue-600 flex items-center gap-2"
|
||||
>
|
||||
<MapPin className="w-3 h-3" /> Thêm vào chặng hiện tại
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
latitude: 10.7769,
|
||||
longitude: 106.7009,
|
||||
type: 'VISIT',
|
||||
legId: '',
|
||||
note: '',
|
||||
expenseAmount: '',
|
||||
expenseCategory: 'OTHER',
|
||||
expenseDescription: '',
|
||||
expenseNote: '',
|
||||
paidById: '',
|
||||
plannedStart: '',
|
||||
plannedEnd: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Gom các selector lại để giảm số lượng Hook gọi nội bộ và tăng hiệu năng
|
||||
const { legs, addLocation, updateLocation, mapCenter, currentTour } = useTourStore();
|
||||
|
||||
// 1. Luôn khai báo Hook ở cấp cao nhất, không đặt code logic/return giữa các Hook
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingLocation) {
|
||||
const leg = legs.find(l => l.id === editingLocation.legId);
|
||||
const expense = leg?.expenses?.find((e: any) => e.locationId === editingLocation.id);
|
||||
|
||||
setFormData({
|
||||
name: editingLocation.name || '',
|
||||
address: editingLocation.address || '',
|
||||
latitude: editingLocation.latitude,
|
||||
longitude: editingLocation.longitude,
|
||||
type: editingLocation.type || 'VISIT',
|
||||
legId: editingLocation.legId || '',
|
||||
note: editingLocation.note || '',
|
||||
expenseAmount: expense?.amount?.toString() || '',
|
||||
expenseCategory: expense?.category || 'OTHER',
|
||||
expenseDescription: expense?.description || '',
|
||||
expenseNote: expense?.note || '',
|
||||
paidById: expense?.paidById || '',
|
||||
plannedStart: editingLocation.plannedStart ? editingLocation.plannedStart.slice(0, 16) : '',
|
||||
plannedEnd: editingLocation.plannedEnd ? editingLocation.plannedEnd.slice(0, 16) : ''
|
||||
});
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: '', address: '',
|
||||
legId: initialLegId || (legs.length > 0 ? legs[0].id : ''),
|
||||
note: '', expenseAmount: '', expenseCategory: 'OTHER',
|
||||
expenseDescription: '', expenseNote: '', paidById: '',
|
||||
plannedStart: '', plannedEnd: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [initialLegId, editingLocation, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !formData.name) {
|
||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||
}
|
||||
}, [isOpen, mapCenter]);
|
||||
|
||||
// 2. Thực hiện các tính toán và hàm xử lý
|
||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||
const titleText = editingLocation ? `Sửa địa điểm: ${editingLocation.name}` : (initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới');
|
||||
const buttonText = editingLocation ? 'Cập nhật thay đổi' : (initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm');
|
||||
|
||||
const handlePickLocation = async (latlng: L.LatLng) => {
|
||||
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||
|
||||
// Tự động lấy tên địa điểm từ tọa độ vừa chọn
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latlng.lat}&lon=${latlng.lng}`);
|
||||
const data = await res.json();
|
||||
const addr = data.address;
|
||||
const detectedName = addr.amenity || addr.building || addr.historic || addr.tourist ||
|
||||
addr.shop || addr.office || addr.leisure || addr.attraction ||
|
||||
addr.road || addr.neighbourhood || addr.suburb ||
|
||||
data.display_name?.split(',')[0] || "";
|
||||
|
||||
setFormData(prev => ({ ...prev, name: detectedName || prev.name, address: data.display_name || prev.address }));
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// 3. Early return phải nằm SAU tất cả các khai báo Hook
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
};
|
||||
|
||||
if (editingLocation) {
|
||||
await updateLocation(editingLocation.id, payload);
|
||||
} else {
|
||||
await addLocation(tourId, payload);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert('Lỗi khi lưu địa điểm');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-lg bg-white rounded-3xl shadow-2xl overflow-hidden p-8 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<MapIcon className="w-6 h-6 text-blue-600" /> {titleText}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mini Map Picker */}
|
||||
<div className="h-48 w-full rounded-2xl overflow-hidden mb-6 border border-gray-100 relative shadow-inner group">
|
||||
<MapContainer center={[formData.latitude, formData.longitude]} zoom={13} className="h-full w-full">
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Marker position={[formData.latitude, formData.longitude]} />
|
||||
<MapPicker center={[formData.latitude, formData.longitude]} onPick={handlePickLocation} />
|
||||
</MapContainer>
|
||||
<div className="absolute bottom-2 left-2 z-[1000] bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-[10px] font-black text-gray-500 shadow-sm border border-gray-100">
|
||||
CHUỘT PHẢI ĐỂ CHỌN VỊ TRÍ
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên địa điểm</label>
|
||||
<input required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Địa chỉ</label>
|
||||
<input className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.address} onChange={e => setFormData({...formData, address: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Ghi chú địa điểm / Dịch vụ sử dụng</label>
|
||||
<textarea className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none resize-none" rows={2}
|
||||
placeholder="Ví dụ: Ăn trưa tại quán X, thuê hướng dẫn viên..."
|
||||
value={formData.note} onChange={e => setFormData({...formData, note: e.target.value})} />
|
||||
</div>
|
||||
<div className="bg-blue-50/60 border border-blue-100 rounded-2xl p-4 space-y-3">
|
||||
<p className="text-xs font-black text-blue-500 uppercase tracking-widest">Chi phí nhanh tại điểm này</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Số tiền (VNĐ)</label>
|
||||
<input type="number" className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
placeholder="0"
|
||||
value={formData.expenseAmount} onChange={e => setFormData({...formData, expenseAmount: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Loại dịch vụ</label>
|
||||
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={formData.expenseCategory} onChange={e => setFormData({...formData, expenseCategory: e.target.value})}>
|
||||
<option value="FOOD">Ăn uống</option>
|
||||
<option value="TRANSPORT">Di chuyển</option>
|
||||
<option value="ACCOMMODATION">Chỗ ở</option>
|
||||
<option value="TICKET">Vé tham quan</option>
|
||||
<option value="OTHER">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Dịch vụ / Mô tả</label>
|
||||
<input className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
placeholder="Ví dụ: Ăn trưa, taxi, vé..."
|
||||
value={formData.expenseDescription} onChange={e => setFormData({...formData, expenseDescription: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Ghi chú chi phí</label>
|
||||
<textarea className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none resize-none text-sm" rows={2}
|
||||
placeholder="Ghi chú thêm..."
|
||||
value={formData.expenseNote} onChange={e => setFormData({...formData, expenseNote: e.target.value})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 mb-1">Thành viên đã thanh toán</label>
|
||||
<select className="w-full px-3 py-2 bg-white border border-gray-200 rounded-xl outline-none text-sm"
|
||||
value={formData.paidById} onChange={e => setFormData({...formData, paidById: e.target.value})}>
|
||||
<option value="">-- Chọn người thanh toán --</option>
|
||||
{currentTour?.participants?.map((p: any) => {
|
||||
const name = p.user?.name;
|
||||
const email = p.user?.email;
|
||||
const label = [name, email && name ? `(${email})` : email].filter(Boolean).join(' ');
|
||||
return (
|
||||
<option key={p.userId} value={p.userId}>{label || p.userId}</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Gán vào chặng</label>
|
||||
<select required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={currentLegId} onChange={e => setFormData({...formData, legId: e.target.value})}>
|
||||
{legs.map(leg => (
|
||||
<option key={leg.id} value={leg.id}>Chặng {leg.sequence}: {leg.note || 'Không có tên'}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Vĩ độ</label>
|
||||
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.latitude} onChange={e => setFormData({...formData, latitude: e.target.value as any})} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kinh độ</label>
|
||||
<input type="number" step="any" required className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.longitude} onChange={e => setFormData({...formData, longitude: e.target.value as any})} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Loại</label>
|
||||
<select className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.type} onChange={e => setFormData({...formData, type: e.target.value as any})}>
|
||||
<option value="VISIT">Tham quan</option>
|
||||
<option value="EAT">Ăn uống</option>
|
||||
<option value="REST">Nghỉ ngơi</option>
|
||||
<option value="MOVE">Di chuyển</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||
<input type="datetime-local" className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none"
|
||||
value={formData.plannedStart} onChange={e => setFormData({...formData, plannedStart: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={isLoading} className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl flex items-center justify-center gap-2 mt-4">
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : buttonText}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { X, Search, UserPlus, Loader2, Shield, Trash2, Clock, Check } from 'lucide-react';
|
||||
|
||||
interface AddMemberModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
participants?: Array<{ userId: string; role: string; user?: { id: string; name: string; email: string } }>;
|
||||
joinRequests?: Array<{ id: string; userId: string; user?: { id: string; name: string; email: string }; status: string; requestedById: string }>;
|
||||
onRemoveMember?: (userId: string) => Promise<void>;
|
||||
onMemberAdded?: () => void;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export const AddMemberModal: React.FC<AddMemberModalProps> = ({ isOpen, onClose, tourId, participants = [], joinRequests = [], onRemoveMember, onMemberAdded, userRole }) => {
|
||||
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 [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [confirmTarget, setConfirmTarget] = useState<{ userId: string; name: string } | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const participantIds = useMemo(() => new Set(participants.map((p) => p.userId)), [participants]);
|
||||
const requestUserIds = useMemo(() => new Set(joinRequests.map((r) => (r as any).userId)), [joinRequests]);
|
||||
const visibleUsers = useMemo(() => users.filter((u) => !participantIds.has(u.id)), [users, participantIds]);
|
||||
|
||||
const canCreateDirectly = !userRole || userRole === 'OWNER' || userRole === 'MANAGER';
|
||||
|
||||
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(Array.isArray(data) ? 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('');
|
||||
setSubmitError('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleRemove = async (userId: string, memberName: string) => {
|
||||
if (!onRemoveMember) return;
|
||||
setConfirmTarget({ userId, name: memberName });
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmRemove = async () => {
|
||||
if (!confirmTarget || !onRemoveMember) return;
|
||||
try {
|
||||
await onRemoveMember(confirmTarget.userId);
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Không thể xóa thành viên');
|
||||
} finally {
|
||||
setIsConfirmOpen(false);
|
||||
setConfirmTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestAction = async (reqId: string, action: 'accept' | 'reject', userName: string) => {
|
||||
if (!onMemberAdded) return;
|
||||
setActionLoading(reqId);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const endpoint = action === 'accept'
|
||||
? `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/accept`
|
||||
: `${API_BASE}/api/v1/tours/${tourId}/join-requests/${reqId}/reject`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || (action === 'accept' ? 'Không thể chấp nhận' : 'Không thể từ chối'));
|
||||
}
|
||||
await onMemberAdded();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Thao tác thất bại');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedUser) return;
|
||||
setSubmitting(true);
|
||||
setSubmitError('');
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const endpoint = canCreateDirectly ? `/api/v1/tours/${tourId}/members` : `/api/v1/tours/${tourId}/join-requests`;
|
||||
const body = canCreateDirectly
|
||||
? { userId: selectedUser, role }
|
||||
: { userId: selectedUser };
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message || data.error || 'Thao tác thất bại');
|
||||
}
|
||||
await onMemberAdded?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Thao tác thất bại');
|
||||
} 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" /> {canCreateDirectly ? 'Thêm thành viên' : 'Mời tham gia tour'}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
{canCreateDirectly ? 'Chọn người dùng và phân quyền cho tour này.' : 'Mời sẽ được gửi và chờ người quản lý phê duyệt.'}
|
||||
</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>
|
||||
<p className="text-[11px] font-bold text-gray-500 mb-2">Thành viên của tour ({participants.length})</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{participants.map((p) => {
|
||||
const rawToken = localStorage.getItem('token');
|
||||
let currentUserId: string | null = null;
|
||||
try {
|
||||
const payload = JSON.parse(atob((rawToken || '').split('.')[1]));
|
||||
currentUserId = payload.sub;
|
||||
} catch {
|
||||
currentUserId = null;
|
||||
}
|
||||
const isCurrentUser = currentUserId && p.userId === currentUserId;
|
||||
const isOwner = p.role === 'OWNER';
|
||||
const canRemove = onRemoveMember && !isCurrentUser && !isOwner;
|
||||
return (
|
||||
<div key={p.userId} className="flex flex-col items-center gap-1">
|
||||
<div className="relative">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||
{p.user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
onClick={() => handleRemove(p.userId, p.user?.name || p.userId)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-gray-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{p.user?.name || p.userId}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{participants.length === 0 && (
|
||||
<span className="text-xs text-gray-400">Chưa có thành viên nào</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{joinRequests.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-gray-500 mb-2 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-500" /> Đang chờ phê duyệt ({joinRequests.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(joinRequests as any[]).map((req) => (
|
||||
<div key={req.id} className="flex flex-col items-center gap-1 relative">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-600 font-bold text-sm overflow-hidden">
|
||||
{req.user?.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="absolute -top-1 -right-1 flex">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'accept', req.user?.name || req.userId)}
|
||||
className="w-4 h-4 bg-green-500 hover:bg-green-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50"
|
||||
aria-label="Accept"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading === req.id}
|
||||
onClick={() => handleRequestAction(req.id, 'reject', req.user?.name || req.userId)}
|
||||
className="w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center text-white text-[10px] leading-none disabled:opacity-50 -ml-1"
|
||||
aria-label="Reject"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-700 max-w-[72px] truncate">{req.user?.name || req.userId}</span>
|
||||
<span className="text-[9px] font-bold text-amber-600 bg-amber-100 px-1.5 py-0.5 rounded-full border border-amber-200">PENDING</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canCreateDirectly && (
|
||||
<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>
|
||||
)}
|
||||
{submitError && (
|
||||
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-bold border border-red-100">
|
||||
{submitError}
|
||||
</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">
|
||||
{visibleUsers.map((u) => {
|
||||
const isSelected = selectedUser === u.id;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUser(u.id)}
|
||||
disabled={requestUserIds.has(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'
|
||||
} ${requestUserIds.has(u.id) ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<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 && visibleUsers.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 xử lý...' : canCreateDirectly ? 'Thêm vào tour' : 'Gửi lời mời'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConfirmOpen && (
|
||||
<div className="fixed inset-0 z-[2100] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={() => setIsConfirmOpen(false)} />
|
||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||
<h3 className="text-base font-bold text-gray-900">Xác nhận xóa thành viên</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Bạn có chắc muốn xóa <span className="font-semibold text-gray-800">{confirmTarget?.name}</span> khỏi tour này?
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={() => setIsConfirmOpen(false)} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
Hủy
|
||||
</button>
|
||||
<button onClick={confirmRemove} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
isOpen,
|
||||
title = 'Xác nhận',
|
||||
message,
|
||||
confirmText = 'Xác nhận',
|
||||
cancelText = 'Hủy',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2200] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-gray-900/70 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl p-5">
|
||||
<h3 className="text-base font-bold text-gray-900">{title}</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">{message}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-2 rounded-xl text-sm font-bold text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
{cancelText}
|
||||
</button>
|
||||
<button onClick={onConfirm} className="px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold transition-colors">
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
|
||||
export const CreateTourModal = ({ isOpen, onClose, onSuccess }: { isOpen: boolean, onClose: () => void, onSuccess: (tour: any) => void }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createTour = useTourStore((state) => state.createTour);
|
||||
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const searchUsers = async (value: string) => {
|
||||
setQuery(value);
|
||||
if (!value.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/users?q=${encodeURIComponent(value)}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Không thể tải người dùng');
|
||||
const data = await res.json();
|
||||
setResults(Array.isArray(data) ? data : []);
|
||||
} catch (e: any) {
|
||||
setResults([]);
|
||||
setError(e.message || 'Không thể tải người dùng');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAddMember = (user: any) => {
|
||||
setMembers((prev) => (prev.some((m) => m.id === user.id) ? prev : [...prev, user]));
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const removeMember = (userId: string) => {
|
||||
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const tour = await createTour({ title, startDate, endDate, memberIds });
|
||||
onSuccess(tour);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Lỗi khi tạo tour');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Tạo Tour mới</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Tên Tour</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="VD: Khám phá Đà Lạt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Bắt đầu</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-1">Kết thúc</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-gray-700 mb-2">Thành viên tham gia</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="relative">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-blue-700 font-bold text-sm overflow-hidden">
|
||||
{m.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(m.id)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-gray-500 hover:bg-red-600 text-white flex items-center justify-center transition-colors"
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<div className="text-[10px] text-center mt-1 max-w-[70px] truncate">{m.name}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
className="w-36 px-2 py-1 text-sm border border-gray-200 rounded-lg outline-none"
|
||||
placeholder="Tìm email..."
|
||||
value={query}
|
||||
onChange={(e) => searchUsers(e.target.value)}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-lg z-10 max-h-60 overflow-y-auto">
|
||||
{results.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => confirmAddMember(u)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-blue-50"
|
||||
>
|
||||
<span className="font-bold text-gray-900">{u.name}</span>
|
||||
<span className="block text-xs text-gray-500">{u.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? 'Đang tạo...' : 'Xác nhận tạo Tour'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Wallet, Users, Info } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
|
||||
export const ExpenseManager = () => {
|
||||
const { legs } = useTourStore();
|
||||
const [adults, setAdults] = useState(2);
|
||||
const [children, setChildren] = useState(1);
|
||||
const [discount, setDiscount] = useState(30);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalAmount = legs.reduce((acc, leg) =>
|
||||
acc + leg.expenses.reduce((lAcc: number, exp: any) => lAcc + Number(exp.amount), 0), 0
|
||||
);
|
||||
|
||||
const childRateFactor = 1 - (discount / 100);
|
||||
const weightedCount = adults + (children * childRateFactor);
|
||||
const adultPrice = totalAmount / weightedCount;
|
||||
const childPrice = adultPrice * childRateFactor;
|
||||
|
||||
return {
|
||||
total: totalAmount,
|
||||
adultPrice: Math.round(adultPrice),
|
||||
childPrice: Math.round(childPrice)
|
||||
};
|
||||
}, [legs, adults, children, discount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-6 text-blue-600">
|
||||
<Users className="w-5 h-5" />
|
||||
<h3 className="font-bold">Cấu hình thành viên</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Người lớn</label>
|
||||
<input type="number" value={adults} onChange={e => setAdults(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Trẻ em</label>
|
||||
<input type="number" value={children} onChange={e => setChildren(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Giảm trẻ em (%)</label>
|
||||
<input type="number" value={discount} onChange={e => setDiscount(Number(e.target.value))} className="w-full p-2 bg-gray-50 rounded-lg border-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-600 rounded-2xl p-6 text-white shadow-lg shadow-blue-200">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Tổng chi phí chuyến đi</p>
|
||||
<h2 className="text-3xl font-bold mt-1">{totals.total.toLocaleString()} VND</h2>
|
||||
</div>
|
||||
<Wallet className="w-8 h-8 opacity-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-blue-500 pt-6">
|
||||
<div>
|
||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi người lớn</p>
|
||||
<p className="text-xl font-bold">{totals.adultPrice.toLocaleString()}đ</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-blue-100 text-xs uppercase tracking-wider font-semibold">Mỗi trẻ em (-{discount}%)</p>
|
||||
<p className="text-xl font-bold">{totals.childPrice.toLocaleString()}đ</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs px-2">
|
||||
<Info className="w-4 h-4" />
|
||||
<p>Chi phí được tự động tính toán dựa trên hóa đơn của các chặng.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
import React, { useState } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||
|
||||
const TimeVariance = ({ planned, actual }: { planned: string, actual: string | null }) => {
|
||||
if (!actual) return null;
|
||||
|
||||
const diff = differenceInMinutes(parseISO(actual), parseISO(planned));
|
||||
const isLate = diff > 0;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center text-xs font-medium mt-1 ${isLate ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{isLate ? <AlertCircle className="w-3 h-3 mr-1" /> : <CheckCircle2 className="w-3 h-3 mr-1" />}
|
||||
<span>
|
||||
{isLate ? `Trễ ${diff} phút` : diff === 0 ? 'Đúng giờ' : `Sớm ${Math.abs(diff)} phút`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
|
||||
const p = 0.017453292519943295; // Math.PI / 180
|
||||
const c = Math.cos;
|
||||
const a = 0.5 - c((lat2 - lat1) * p) / 2 +
|
||||
c(lat1 * p) * c(lat2 * p) *
|
||||
(1 - c((lon2 - lon1) * p)) / 2;
|
||||
return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
|
||||
};
|
||||
|
||||
const formatTravelTime = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes} phút`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
|
||||
export const ItineraryTimeline = ({
|
||||
onAddLocation,
|
||||
onEditLocation
|
||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||
// Tối ưu hóa selectors để chỉ lắng nghe những thay đổi cần thiết
|
||||
const currentTour = useTourStore(state => state.currentTour);
|
||||
const legs = useTourStore(state => state.legs);
|
||||
const userRole = useTourStore(state => state.userRole);
|
||||
const optimizeRouting = useTourStore(state => state.optimizeRouting);
|
||||
const addLeg = useTourStore(state => state.addLeg);
|
||||
const updateLeg = useTourStore(state => state.updateLeg);
|
||||
const deleteLeg = useTourStore(state => state.deleteLeg);
|
||||
const initializeLegs = useTourStore(state => state.initializeLegs);
|
||||
const deleteLocation = useTourStore(state => state.deleteLocation);
|
||||
|
||||
const [confirmState, setConfirmState] = useState<{ open: boolean; title?: string; message?: string; onConfirm?: () => void }>({ open: false });
|
||||
|
||||
const toggleComplete = async (locationId: string) => {
|
||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||
console.log("Toggle status for location:", locationId);
|
||||
};
|
||||
|
||||
const handleAddLeg = async () => {
|
||||
const note = window.prompt("Nhập ghi chú cho chặng mới:", `Chặng ${legs.length + 1}`);
|
||||
if (note && currentTour) {
|
||||
await addLeg(currentTour.id, { note });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclareLegs = async () => {
|
||||
const countStr = window.prompt("Chuyến đi này bạn muốn chia làm bao nhiêu chặng?", "3");
|
||||
const count = parseInt(countStr || "0");
|
||||
if (count > 0 && currentTour) {
|
||||
await initializeLegs(currentTour.id, count);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditLeg = async (leg: any) => {
|
||||
const note = window.prompt("Sửa ghi chú chặng:", leg.note || "");
|
||||
if (note !== null) {
|
||||
await updateLeg(leg.id, { note });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLeg = async (legId: string) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa chặng',
|
||||
message: 'Bạn có chắc chắn muốn xóa chặng này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLeg(legId);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLocation = async (id: string) => {
|
||||
setConfirmState({
|
||||
open: true,
|
||||
title: 'Xóa địa điểm',
|
||||
message: 'Bạn có chắc chắn muốn xóa địa điểm này?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
} catch (err: any) { alert(err.message); } finally {
|
||||
setConfirmState({ open: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
{legs.length === 0 ? (
|
||||
<div className="text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200">
|
||||
<List className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 font-medium">Chưa có chặng nào trong lộ trình.</p>
|
||||
</div>
|
||||
) : (
|
||||
legs.map((leg, legIdx) => {
|
||||
const totalDwellMinutes = leg.locations.reduce((acc: number, loc: any) => {
|
||||
if (loc.plannedStart && loc.plannedEnd) {
|
||||
return acc + differenceInMinutes(parseISO(loc.plannedEnd), parseISO(loc.plannedStart));
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
// Xác định địa điểm cuối cùng của chặng trước đó để hiển thị tính liên tục
|
||||
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
|
||||
|
||||
return (
|
||||
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
{/* Leg Header */}
|
||||
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
|
||||
<div className="font-black text-blue-600 text-xl truncate flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-blue-600 text-white w-8 h-8 rounded-lg flex items-center justify-center text-sm">
|
||||
{leg.sequence}
|
||||
</span>
|
||||
{leg.note || `Chi tiết Chặng ${leg.sequence}`}
|
||||
</div>
|
||||
{prevLegLastLoc && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400 uppercase tracking-widest ml-10">
|
||||
<Navigation className="w-2.5 h-2.5 rotate-90" /> Tiếp nối từ {prevLegLastLoc.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAddLocation?.(leg.id)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||
title="Thêm địa điểm vào chặng này"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditLeg(leg)}
|
||||
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteLeg(leg.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{leg.totalDistance !== undefined && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<div className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded-lg border border-blue-100">
|
||||
{leg.totalDistance} km
|
||||
</div>
|
||||
<div className="text-xs font-bold text-gray-500 bg-gray-50 px-2 py-1 rounded-lg border border-gray-100 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
~ {formatTravelTime(Math.round((leg.totalDistance / 35) * 60))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDwellMinutes > 0 && (
|
||||
<div className="hidden md:flex text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && legs.length > 0 && (
|
||||
<button
|
||||
onClick={() => optimizeRouting(leg.id)}
|
||||
className="ml-auto flex items-center gap-1.5 text-xs font-bold text-indigo-600 hover:text-indigo-700 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-xl transition-all"
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
Tối ưu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertical Line for the whole leg */}
|
||||
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
|
||||
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
|
||||
|
||||
<div className="ml-2">
|
||||
{leg.locations.map((location, idx) => {
|
||||
// Logic quan trọng: Gán điểm cuối chặng này nối với điểm đầu chặng sau
|
||||
const nextLocation = leg.locations[idx + 1] || legs[legIdx + 1]?.locations[0];
|
||||
const distanceToNext = nextLocation
|
||||
? calculateDistance(location.latitude, location.longitude, nextLocation.latitude, nextLocation.longitude)
|
||||
: null;
|
||||
|
||||
const averageSpeed = 35; // km/h
|
||||
const travelTimeMinutes = distanceToNext ? Math.round((distanceToNext / averageSpeed) * 60) : null;
|
||||
|
||||
const dwellMinutes = (location.plannedStart && location.plannedEnd)
|
||||
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
||||
: null;
|
||||
|
||||
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
||||
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
|
||||
return (
|
||||
<div key={location.id}>
|
||||
<div className="relative flex group mb-6">
|
||||
{/* Timeline Node */}
|
||||
<div className="z-10 mt-1.5 mr-4">
|
||||
<button
|
||||
onClick={() => toggleComplete(location.id)}
|
||||
className={`transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`}
|
||||
>
|
||||
{location.status === 'COMPLETED' ? (
|
||||
<CheckCircle2 className="w-8 h-8 bg-white rounded-full" />
|
||||
) : (
|
||||
<Circle className="w-8 h-8 bg-white rounded-full fill-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Content */}
|
||||
<div className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 ${
|
||||
location.status === 'COMPLETED' ? 'border-green-100 bg-green-50/30' : 'border-gray-100 shadow-sm hover:shadow-md'
|
||||
}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
{isStartPoint && (
|
||||
<span className="inline-block px-2 py-0.5 bg-green-100 text-green-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm bắt đầu</span>
|
||||
)}
|
||||
{isEndPoint && (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-100 text-red-700 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
||||
)}
|
||||
<h3 className={`font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`}>
|
||||
{location.name}
|
||||
</h3>
|
||||
<div className="flex items-center text-sm text-gray-500 mt-1">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
<span className="truncate max-w-[200px] sm:max-w-md">{location.address}</span>
|
||||
</div>
|
||||
{location.note && (
|
||||
<div className="mt-2 text-xs text-gray-600 bg-gray-50 p-2 rounded-lg border border-gray-100 italic">
|
||||
{location.note}
|
||||
</div>
|
||||
)}
|
||||
{dwellMinutes !== null && (
|
||||
<div className="flex items-center text-xs text-amber-600 font-medium mt-1">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
<span>Thời gian dừng: {formatTravelTime(dwellMinutes)}</span>
|
||||
</div>
|
||||
)}
|
||||
{locationExpense && (
|
||||
<div className="mt-2 text-xs text-indigo-600 bg-indigo-50/80 p-2 rounded-lg border border-indigo-100 space-y-1">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>Chi phí: {Number(locationExpense.amount).toLocaleString()}đ ({locationExpense.category})</span>
|
||||
</div>
|
||||
{locationExpense.description && (
|
||||
<div className="text-[10px] text-gray-600">Dịch vụ: {locationExpense.description}</div>
|
||||
)}
|
||||
{locationExpense.note && (
|
||||
<div className="text-[10px] text-gray-500 italic">{locationExpense.note}</div>
|
||||
)}
|
||||
{locationExpense.paidBy && (
|
||||
<div className="text-[10px] font-semibold text-indigo-700">Đã thanh toán: {locationExpense.paidBy.name}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right flex flex-col items-end">
|
||||
<div className="flex items-center text-sm font-medium text-blue-600">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'}
|
||||
</div>
|
||||
{location.status === 'COMPLETED' && location.actualStart && (
|
||||
<div className="text-[10px] text-gray-400 mt-1 italic">
|
||||
Thực tế: {format(parseISO(location.actualStart), 'HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button onClick={() => onEditLocation?.(location)} className="p-1 text-gray-400 hover:text-blue-600 transition-colors">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteLocation(location.id)} className="p-1 text-gray-400 hover:text-red-600 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logic tính toán độ lệch thời gian */}
|
||||
<TimeVariance planned={location.plannedStart || ''} actual={location.actualStart || null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{distanceToNext !== null && travelTimeMinutes !== null && (
|
||||
<div className="ml-4 -mt-4 mb-2 flex items-center gap-3">
|
||||
<div className="w-8 flex justify-center">
|
||||
<Navigation className="w-3 h-3 text-blue-400 rotate-180" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100">
|
||||
{distanceToNext.toFixed(2)} km
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100 flex items-center gap-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
~ {formatTravelTime(travelTimeMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Actions at the bottom of the list */}
|
||||
{['OWNER', 'MANAGER'].includes(userRole || '') && (
|
||||
<div className="flex flex-col gap-3 pb-20 mt-8">
|
||||
<button
|
||||
onClick={handleDeclareLegs}
|
||||
className="w-full py-4 rounded-2xl bg-white text-blue-600 border-2 border-dashed border-blue-200 hover:bg-blue-50 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
{legs.length > 0 ? "Khai báo lại số lượng chặng" : "Bắt đầu bằng việc khai báo số chặng"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddLeg}
|
||||
className="w-full py-4 rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all flex items-center justify-center gap-2 text-sm font-bold"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Thêm chặng lẻ vào cuối
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
isOpen={confirmState.open}
|
||||
title={confirmState.title}
|
||||
message={confirmState.message}
|
||||
onConfirm={() => confirmState.onConfirm?.()}
|
||||
onCancel={() => setConfirmState({ open: false })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSwitchToSignup?: () => void;
|
||||
onLoginSuccess?: (user: any) => void;
|
||||
}
|
||||
|
||||
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onSwitchToSignup, onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Đăng nhập thất bại');
|
||||
}
|
||||
|
||||
// Lưu phiên đăng nhập
|
||||
localStorage.setItem('token', data.access_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
if (onLoginSuccess) {
|
||||
onLoginSuccess(data.user);
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
||||
<div className="p-8 sm:p-10">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Đăng nhập</h2>
|
||||
<p className="text-gray-500 mt-2">Chào mừng bạn quay trở lại!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-2xl text-sm font-bold animate-in fade-in slide-in-from-top-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-gray-700 ml-1">Email</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<label className="text-sm font-semibold text-gray-700">Mật khẩu</label>
|
||||
<button className="text-xs font-bold text-blue-600 hover:text-blue-700">Quên mật khẩu?</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:bg-white outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 rounded-2xl shadow-lg shadow-blue-200 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
|
||||
{isLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : <ArrowRight className="w-5 h-5" />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-10 pt-8 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-500">
|
||||
Chưa có tài khoản?{' '}
|
||||
<button
|
||||
onClick={() => { onClose(); onSwitchToSignup?.(); }}
|
||||
className="font-bold text-blue-600 hover:underline"
|
||||
>
|
||||
Tạo tài khoản ngay
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||
|
||||
interface NotificationModalProps {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationModal - Component hiển thị thông báo phản hồi cho người dùng
|
||||
*/
|
||||
export const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||
isOpen,
|
||||
title = 'Thông báo',
|
||||
message,
|
||||
type = 'info',
|
||||
onConfirm,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="w-12 h-12 text-green-500" />,
|
||||
error: <AlertCircle className="w-12 h-12 text-red-500" />,
|
||||
info: <Info className="w-12 h-12 text-blue-500" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-600 hover:bg-green-700 shadow-green-100',
|
||||
error: 'bg-red-600 hover:bg-red-700 shadow-red-100',
|
||||
info: 'bg-blue-600 hover:bg-blue-700 shadow-blue-100',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[3000] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={onConfirm} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative w-full max-w-sm bg-white rounded-[32px] shadow-2xl overflow-hidden p-8 text-center animate-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-center mb-5">
|
||||
{icons[type]}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-black text-gray-900 mb-2">{title}</h2>
|
||||
<p className="text-gray-500 text-sm leading-relaxed mb-8">
|
||||
{message || "Bạn không được phép gỡ bỏ thành viên này!"}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`w-full py-4 text-white font-bold rounded-2xl transition-all shadow-lg active:scale-95 ${colors[type]}`}
|
||||
>
|
||||
Đã hiểu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook để quản lý trạng thái của NotificationModal
|
||||
*/
|
||||
export const useNotificationModal = () => {
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
type?: 'success' | 'error' | 'info';
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: 'Thông báo',
|
||||
message: '',
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
const openModal = (title: string, message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
setModalState({ isOpen: true, title, message, type });
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||
};
|
||||
|
||||
return { modalState, openModal, closeModal };
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, User, Trash2, Shield, ShieldAlert, Lock, Unlock, Loader2, UserPlus } from 'lucide-react';
|
||||
|
||||
interface UserManagementModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UserManagementModal: React.FC<UserManagementModalProps> = ({ isOpen, onClose }) => {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/users`, {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!response.ok) throw new Error('Không thể tải danh sách người dùng');
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchUsers();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleToggleBlock = async (id: string) => {
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
await fetch(`${API_BASE}/api/v1/users/block/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert('Lỗi khi thay đổi trạng thái block');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này?')) return;
|
||||
try {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const res = await fetch(`${API_BASE}/api/v1/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message);
|
||||
}
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
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-4xl bg-white rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-blue-600" /> Quản lý người dùng
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Quản trị viên có quyền thêm, sửa, xóa hoặc khóa tài khoản.</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
|
||||
<X className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="w-10 h-10 animate-spin text-blue-600" /></div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-xl font-bold">{error}</div>
|
||||
) : (
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="text-gray-400 text-xs uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="pb-4 font-bold px-2">Người dùng</th>
|
||||
<th className="pb-4 font-bold">Vai trò</th>
|
||||
<th className="pb-4 font-bold">Trạng thái</th>
|
||||
<th className="pb-4 font-bold text-right">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="group hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||
{u.name?.charAt(0) || <User className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-gray-900">{u.name || 'N/A'}</div>
|
||||
<div className="text-xs text-gray-400">{u.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4">
|
||||
{u.isAdmin ? (
|
||||
<span className="px-2 py-1 bg-purple-50 text-purple-600 text-[10px] font-black rounded-md border border-purple-100">ADMIN</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-gray-50 text-gray-500 text-[10px] font-black rounded-md border border-gray-100">USER</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4">
|
||||
{u.isBlocked ? (
|
||||
<span className="flex items-center gap-1 text-red-500 text-xs font-bold"><ShieldAlert className="w-3 h-3" /> Đã khóa</span>
|
||||
) : (
|
||||
<span className="text-green-500 text-xs font-bold">Đang hoạt động</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleBlock(u.id)}
|
||||
className={`p-2 rounded-xl transition-all ${u.isBlocked ? 'bg-green-50 text-green-600 hover:bg-green-100' : 'bg-amber-50 text-amber-600 hover:bg-amber-100'}`}
|
||||
title={u.isBlocked ? 'Mở khóa' : 'Khóa tài khoản'}
|
||||
>
|
||||
{u.isBlocked ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id)}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-all"
|
||||
title="Xóa người dùng"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user