fix: gửi vị trí khẩn cấp trong Dashboard

This commit is contained in:
2026-06-23 12:44:38 +07:00
parent 6b15e7ff02
commit 5145835c8a
6 changed files with 250 additions and 69 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

+20
View File
@@ -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
+20 -16
View File
@@ -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,7 +258,22 @@ export const ItineraryTimeline = ({
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}>
{/* 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
@@ -284,7 +298,7 @@ export const ItineraryTimeline = ({
</>
)}
{leg.totalDistance !== undefined && (
<div className="hidden sm:flex items-center gap-2">
<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,23 +308,13 @@ 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">
{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>
{['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 */}
</div>
{/* Scrollable content body with proper z-index layering */}
<div className="child-nodes-list-wrapper">
+2 -1
View File
@@ -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 */
+172 -16
View File
@@ -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 {
@@ -154,7 +205,81 @@ export const MemberDashboard: React.FC<MemberDashboardProps> = ({
}
};
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,22 +1425,14 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</span>
</div>
<div className="flex flex-col gap-2 mt-2">
<div className="flex gap-2">
<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>
<ChevronRight className="w-3.5 h-3.5" />
</button>
{/* 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="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"
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>
@@ -1326,16 +1443,55 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-rose-600 rounded-full border border-slate-800" />
)}
</button>
</div>
{/* 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={() => 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={() => 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"
>
<ShieldAlert className="w-3.5 h-3.5 text-rose-500" />
<span>Chia sẻ khẩn cấp</span>
<span>Chi tiết hành trình</span>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Emergency Share Button - moved to bottom */}
<button
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"
>
{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>