fix: gửi vị trí khẩn cấp trong Dashboard
This commit is contained in:
@@ -3,10 +3,30 @@ server {
|
||||
server_name yotrip.labz.io.vn localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# JavaScript and CSS files - immutable caching
|
||||
location ~* \.(?:js|css)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Vary "Accept-Encoding" always;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Images and fonts - long caching
|
||||
location ~* \.(?:jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Main app route - SPA fallback (only for HTML)
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
# Only redirect actual routes to index.html, not assets
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { format, differenceInMinutes, parseISO } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag } from 'lucide-react';
|
||||
import { CheckCircle2, Circle, Clock, MapPin, AlertCircle, Zap, Navigation, Edit2, Trash2, Plus, List, X, Calendar as CalendarIcon, AlignLeft, MessageSquare, FileText, Flag, ChevronDown } from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
@@ -57,7 +57,6 @@ export const ItineraryTimeline = ({
|
||||
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);
|
||||
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
|
||||
@@ -259,32 +258,47 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}>
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0); }}
|
||||
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={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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">
|
||||
{/* Expand/Collapse indicator button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleStageExpanded(leg.id); }}
|
||||
className="ml-2 p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all shrink-0"
|
||||
title={expandedStageId === leg.id ? 'Collapse chặng này' : 'Expand chặng này'}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-300 ${
|
||||
expandedStageId === leg.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons Row - Below stage title */}
|
||||
<div className="px-4 py-2 bg-white border-b border-gray-100 flex items-center gap-2 flex-wrap">
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0); }}
|
||||
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={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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="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>
|
||||
@@ -294,22 +308,12 @@ export const ItineraryTimeline = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totalDwellMinutes > 0 && ( // Always show dwell time
|
||||
<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>
|
||||
)} {/* Only show optimize button if canEdit */}
|
||||
{totalDwellMinutes > 0 && (
|
||||
<div className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-1 rounded-lg border border-amber-100 items-center gap-1 flex">
|
||||
<Clock className="w-3 h-3" />
|
||||
Dừng: {formatTravelTime(totalDwellMinutes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content body with proper z-index layering */}
|
||||
|
||||
@@ -135,9 +135,10 @@
|
||||
.folder-node-wrapper {
|
||||
width: 100% !important;
|
||||
background-color: #ffffff;
|
||||
margin-bottom: 1px !important;
|
||||
margin-bottom: 0px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* Individual stage card block - backwards compatibility */
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
Loader2,
|
||||
Bell,
|
||||
BellOff,
|
||||
ShieldAlert
|
||||
ShieldAlert,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { useTourStore } from '@/store/useTourStore';
|
||||
import { useNotification } from '@/hooks/useNotification';
|
||||
@@ -128,6 +129,56 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmergencyShare = async (tour: any) => {
|
||||
try {
|
||||
setLoadingShare(true);
|
||||
// Get current location
|
||||
const position = await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos.coords),
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Send location message to tour chat
|
||||
const token = localStorage.getItem('token');
|
||||
const message = `📍 Vị trí hiện tại: ${position.latitude.toFixed(6)}, ${position.longitude.toFixed(6)}\n🔗 Google Maps: https://maps.google.com/?q=${position.latitude},${position.longitude}`;
|
||||
|
||||
const res = await fetch(`/api/v1/tours/${tour.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ content: message })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã gửi vị trí hiện tại cho nhóm',
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Không thể gửi vị trí',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error sharing location:', e);
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Không thể lấy vị trí hiện tại',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setLoadingShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleShare = async (isEnabled: boolean) => {
|
||||
if (!sharingTour) return;
|
||||
try {
|
||||
@@ -153,8 +204,82 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakeCameraPhoto = async (tour: any) => {
|
||||
if (cameraInputRef.current) {
|
||||
cameraInputRef.current.click();
|
||||
// Store tour ID for later processing
|
||||
(cameraInputRef.current as any).dataset.tourId = tour.id;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const tourId = (event.target as any).dataset.tourId;
|
||||
// Get current location if available
|
||||
try {
|
||||
setIsLocating(true);
|
||||
const position = await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve(pos.coords),
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: 5000 }
|
||||
);
|
||||
});
|
||||
setAttachedLocation({ latitude: position.latitude, longitude: position.longitude });
|
||||
} catch (e) {
|
||||
console.log('Could not get location:', e);
|
||||
}
|
||||
setIsLocating(false);
|
||||
|
||||
// Upload photo to tour
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('images', file);
|
||||
if (attachedLocation) {
|
||||
formData.append('latitude', attachedLocation.latitude.toString());
|
||||
formData.append('longitude', attachedLocation.longitude.toString());
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/tours/${tourId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
notify({
|
||||
title: 'Thành công',
|
||||
message: 'Đã upload ảnh vào thư viện tour',
|
||||
type: 'success'
|
||||
});
|
||||
// Clear input
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = '';
|
||||
} else {
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Không thể upload ảnh',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload error:', e);
|
||||
notify({
|
||||
title: 'Lỗi',
|
||||
message: 'Lỗi upload ảnh',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
@@ -1300,42 +1425,73 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{/* Chat button - moved to top */}
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('tour_detail_default_tab', 'chat');
|
||||
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
|
||||
onViewTour(tour.id, 'dashboard');
|
||||
}}
|
||||
className="w-full py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>Trò chuyện</span>
|
||||
{unreadTourChats.includes(tour.id) && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full animate-ping border border-slate-800" />
|
||||
)}
|
||||
{unreadTourChats.includes(tour.id) && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Camera + Detail buttons row */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTakeCameraPhoto(tour)}
|
||||
disabled={isUploading}
|
||||
className="flex-1 py-2 px-2 bg-green-600/20 hover:bg-green-600 text-green-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-green-500/30 hover:border-green-500 flex items-center justify-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Camera className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Chụp ảnh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewTour(tour.id, 'dashboard')}
|
||||
className="flex-1 py-2 px-2 bg-slate-900 hover:bg-slate-800 text-slate-350 hover:text-white rounded-xl text-xs font-bold transition-all border border-slate-800 hover:border-slate-700 flex items-center justify-center gap-1"
|
||||
>
|
||||
<span>Xem chi tiết</span>
|
||||
<span>Chi tiết hành trình</span>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('tour_detail_default_tab', 'chat');
|
||||
setUnreadTourChats(prev => prev.filter(id => id !== tour.id));
|
||||
onViewTour(tour.id, 'dashboard');
|
||||
}}
|
||||
className="flex-1 py-2 px-2 bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white rounded-xl text-xs font-bold transition-all border border-indigo-500/30 hover:border-indigo-500 flex items-center justify-center gap-1 relative whitespace-nowrap"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>Trò chuyện</span>
|
||||
{unreadTourChats.includes(tour.id) && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full animate-ping border border-slate-800" />
|
||||
)}
|
||||
{unreadTourChats.includes(tour.id) && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Emergency Share Button - moved to bottom */}
|
||||
<button
|
||||
onClick={() => handleOpenShareModal(tour)}
|
||||
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5"
|
||||
onClick={() => handleEmergencyShare(tour)}
|
||||
disabled={loadingShare}
|
||||
className="w-full py-2 px-3 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-xs font-bold transition-all border border-rose-500/20 hover:border-rose-500 flex items-center justify-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
|
||||
<span>Chia sẻ khẩn cấp</span>
|
||||
{loadingShare ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
|
||||
)}
|
||||
<span>Chia sẻ vị trí khẩn cấp</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden camera input */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user