Bổ sung tính năng sửa chữa, cập nhật địa điểm trong chặng
This commit is contained in:
+41
-15
@@ -59,7 +59,7 @@ const MapPicker = ({ onPick, center }: { onPick: (latlng: L.LatLng) => void, cen
|
||||
);
|
||||
};
|
||||
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string }) => {
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }: { isOpen: boolean, onClose: () => void, tourId: string, initialLegId?: string, editingLocation?: any }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -73,15 +73,32 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }: { is
|
||||
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, mapCenter } = useTourStore();
|
||||
const { legs, addLocation, updateLocation, mapCenter } = 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(() => {
|
||||
// Chỉ cập nhật nếu giá trị thực sự thay đổi để tránh vòng lặp re-render
|
||||
if (initialLegId && formData.legId !== initialLegId) {
|
||||
setFormData(prev => ({ ...prev, legId: initialLegId }));
|
||||
if (isOpen) {
|
||||
if (editingLocation) {
|
||||
setFormData({
|
||||
name: editingLocation.name || '',
|
||||
address: editingLocation.address || '',
|
||||
latitude: editingLocation.latitude,
|
||||
longitude: editingLocation.longitude,
|
||||
type: editingLocation.type || 'VISIT',
|
||||
legId: editingLocation.legId || '',
|
||||
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 : ''),
|
||||
plannedStart: '', plannedEnd: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [initialLegId, isOpen]);
|
||||
}, [initialLegId, editingLocation, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !formData.name) {
|
||||
@@ -92,8 +109,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }: { is
|
||||
// 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 = initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới';
|
||||
const buttonText = initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm';
|
||||
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 }));
|
||||
@@ -119,15 +136,24 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }: { is
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await addLocation(tourId, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
});
|
||||
if (editingLocation) {
|
||||
await updateLocation(editingLocation.id, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
});
|
||||
} else {
|
||||
await addLocation(tourId, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude as any),
|
||||
longitude: parseFloat(formData.longitude as any),
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
alert('Lỗi khi thêm địa điểm');
|
||||
alert('Lỗi khi lưu địa điểm');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
+23
-2
@@ -35,8 +35,11 @@ const formatTravelTime = (minutes: number) => {
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
|
||||
export const ItineraryTimeline = ({ onAddLocation }: { onAddLocation?: (legId: string) => void }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs } = useTourStore();
|
||||
export const ItineraryTimeline = ({
|
||||
onAddLocation,
|
||||
onEditLocation
|
||||
}: { onAddLocation?: (legId: string) => void, onEditLocation?: (location: any) => void }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||
|
||||
const toggleComplete = async (locationId: string) => {
|
||||
// Gọi API PATCH /api/v1/locations/:id để cập nhật trạng thái
|
||||
@@ -75,6 +78,14 @@ export const ItineraryTimeline = ({ onAddLocation }: { onAddLocation?: (legId: s
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLocation = async (id: string) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
} catch (err: any) { alert(err.message); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
|
||||
<div className="px-2 pt-4">
|
||||
@@ -241,6 +252,16 @@ export const ItineraryTimeline = ({ onAddLocation }: { onAddLocation?: (legId: s
|
||||
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>
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
const [viewMode, setViewMode] = useState<'map' | 'timeline'>('timeline');
|
||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||
const [targetLegId, setTargetLegId] = useState<string | null>(null);
|
||||
const [editingLocation, setEditingLocation] = useState<any>(null);
|
||||
|
||||
// Khôi phục vị trí và mức zoom từ localStorage
|
||||
const [initialViewState] = useState(() => {
|
||||
@@ -499,6 +500,12 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
{viewMode === 'timeline' ? (
|
||||
<ItineraryTimeline onAddLocation={(legId) => {
|
||||
setTargetLegId(legId);
|
||||
setEditingLocation(null);
|
||||
setIsAddLocationOpen(true);
|
||||
}} onEditLocation={(loc) => {
|
||||
setEditingLocation(loc);
|
||||
setTargetLegId(loc.legId);
|
||||
setMapCenter([loc.latitude, loc.longitude]);
|
||||
setIsAddLocationOpen(true);
|
||||
}} />
|
||||
) : (
|
||||
@@ -587,6 +594,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetLegId(null); // Reset khi nhấn nút floating tổng quát
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan') setIsAddLocationOpen(true);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold">
|
||||
@@ -600,6 +608,7 @@ export const TourDetailPage = ({ onBack }: { onBack: () => void }) => {
|
||||
isOpen={isAddLocationOpen}
|
||||
onClose={() => setIsAddLocationOpen(false)}
|
||||
initialLegId={targetLegId || undefined}
|
||||
editingLocation={editingLocation}
|
||||
tourId={currentTour.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
Vendored
+2
-1
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
export declare const AddLocationModal: ({ isOpen, onClose, tourId, initialLegId }: {
|
||||
export declare const AddLocationModal: ({ isOpen, onClose, tourId, initialLegId, editingLocation }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tourId: string;
|
||||
initialLegId?: string;
|
||||
editingLocation?: any;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+43
-14
@@ -33,7 +33,7 @@ const MapPicker = ({ onPick, center }) => {
|
||||
}, [center]);
|
||||
return (_jsx(_Fragment, { children: menuPos && (_jsx("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 }, children: _jsxs("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", children: [_jsx(MapPin, { className: "w-3 h-3" }), " Th\u00EAm v\u00E0o ch\u1EB7ng hi\u1EC7n t\u1EA1i"] }) })) }));
|
||||
};
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }) => {
|
||||
export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId, editingLocation }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -45,12 +45,31 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }) => {
|
||||
plannedEnd: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { legs, addLocation, mapCenter } = useTourStore();
|
||||
const { legs, addLocation, updateLocation, mapCenter } = useTourStore();
|
||||
useEffect(() => {
|
||||
if (initialLegId && formData.legId !== initialLegId) {
|
||||
setFormData(prev => ({ ...prev, legId: initialLegId }));
|
||||
if (isOpen) {
|
||||
if (editingLocation) {
|
||||
setFormData({
|
||||
name: editingLocation.name || '',
|
||||
address: editingLocation.address || '',
|
||||
latitude: editingLocation.latitude,
|
||||
longitude: editingLocation.longitude,
|
||||
type: editingLocation.type || 'VISIT',
|
||||
legId: editingLocation.legId || '',
|
||||
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 : ''),
|
||||
plannedStart: '', plannedEnd: ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [initialLegId, isOpen]);
|
||||
}, [initialLegId, editingLocation, isOpen]);
|
||||
useEffect(() => {
|
||||
if (isOpen && !formData.name) {
|
||||
setFormData(prev => ({ ...prev, latitude: mapCenter[0], longitude: mapCenter[1] }));
|
||||
@@ -58,8 +77,8 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }) => {
|
||||
}, [isOpen, mapCenter]);
|
||||
const currentLegId = formData.legId || (legs.length > 0 ? legs[0].id : '');
|
||||
const targetLeg = legs.find(l => l.id === currentLegId);
|
||||
const titleText = initialLegId ? `Thêm địa điểm cho ${targetLeg?.note || `Chặng ${targetLeg?.sequence}`}` : 'Thêm địa điểm mới';
|
||||
const buttonText = initialLegId ? 'Xác nhận thêm vào chặng' : 'Thêm địa điểm';
|
||||
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) => {
|
||||
setFormData(prev => ({ ...prev, latitude: latlng.lat, longitude: latlng.lng }));
|
||||
try {
|
||||
@@ -80,16 +99,26 @@ export const AddLocationModal = ({ isOpen, onClose, tourId, initialLegId }) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await addLocation(tourId, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
});
|
||||
if (editingLocation) {
|
||||
await updateLocation(editingLocation.id, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
});
|
||||
}
|
||||
else {
|
||||
await addLocation(tourId, {
|
||||
...formData,
|
||||
legId: currentLegId,
|
||||
latitude: parseFloat(formData.latitude),
|
||||
longitude: parseFloat(formData.longitude),
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
catch (error) {
|
||||
alert('Lỗi khi thêm địa điểm');
|
||||
alert('Lỗi khi lưu địa điểm');
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
export declare const ItineraryTimeline: ({ onAddLocation }: {
|
||||
export declare const ItineraryTimeline: ({ onAddLocation, onEditLocation }: {
|
||||
onAddLocation?: (legId: string) => void;
|
||||
onEditLocation?: (location: any) => void;
|
||||
}) => React.JSX.Element;
|
||||
|
||||
Vendored
+13
-3
@@ -24,8 +24,8 @@ const formatTravelTime = (minutes) => {
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
|
||||
};
|
||||
export const ItineraryTimeline = ({ onAddLocation }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs } = useTourStore();
|
||||
export const ItineraryTimeline = ({ onAddLocation, onEditLocation }) => {
|
||||
const { currentTour, legs, optimizeRouting, userRole, addLeg, updateLeg, deleteLeg, initializeLegs, deleteLocation } = useTourStore();
|
||||
const toggleComplete = async (locationId) => {
|
||||
console.log("Toggle status for location:", locationId);
|
||||
};
|
||||
@@ -58,6 +58,16 @@ export const ItineraryTimeline = ({ onAddLocation }) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleDeleteLocation = async (id) => {
|
||||
if (window.confirm("Bạn có chắc chắn muốn xóa địa điểm này?")) {
|
||||
try {
|
||||
await deleteLocation(id);
|
||||
}
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (_jsx("div", { className: "max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen", children: _jsxs("div", { className: "px-2 pt-4", children: [legs.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-white rounded-3xl border-2 border-dashed border-gray-200", children: [_jsx(List, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "Ch\u01B0a c\u00F3 ch\u1EB7ng n\u00E0o trong l\u1ED9 tr\u00ECnh." })] })) : (legs.map((leg, legIdx) => {
|
||||
const totalDwellMinutes = leg.locations.reduce((acc, loc) => {
|
||||
if (loc.plannedStart && loc.plannedEnd) {
|
||||
@@ -78,7 +88,7 @@ export const ItineraryTimeline = ({ onAddLocation }) => {
|
||||
: null;
|
||||
const isStartPoint = legs[0]?.id === leg.id && idx === 0;
|
||||
const isEndPoint = legs[legs.length - 1]?.id === leg.id && idx === leg.locations.length - 1;
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("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'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("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", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("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", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("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", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
return (_jsxs("div", { children: [_jsxs("div", { className: "relative flex group mb-6", children: [_jsx("div", { className: "z-10 mt-1.5 mr-4", children: _jsx("button", { onClick: () => toggleComplete(location.id), className: `transition-colors duration-200 ${location.status === 'COMPLETED' ? 'text-green-500' : 'text-gray-300 hover:text-blue-500'}`, children: location.status === 'COMPLETED' ? (_jsx(CheckCircle2, { className: "w-8 h-8 bg-white rounded-full" })) : (_jsx(Circle, { className: "w-8 h-8 bg-white rounded-full fill-white" })) }) }), _jsxs("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'}`, children: [_jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [isStartPoint && (_jsx("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", children: "\u0110i\u1EC3m b\u1EAFt \u0111\u1EA7u" })), isEndPoint && (_jsx("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", children: "\u0110i\u1EC3m k\u1EBFt th\u00FAc" })), _jsx("h3", { className: `font-semibold text-lg ${location.status === 'COMPLETED' ? 'text-gray-500 line-through' : 'text-gray-800'}`, children: location.name }), _jsxs("div", { className: "flex items-center text-sm text-gray-500 mt-1", children: [_jsx(MapPin, { className: "w-3 h-3 mr-1" }), _jsx("span", { className: "truncate max-w-[200px] sm:max-w-md", children: location.address })] }), dwellMinutes !== null && (_jsxs("div", { className: "flex items-center text-xs text-amber-600 font-medium mt-1", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), _jsxs("span", { children: ["Th\u1EDDi gian d\u1EEBng: ", formatTravelTime(dwellMinutes)] })] }))] }), _jsxs("div", { className: "text-right flex flex-col items-end", children: [_jsxs("div", { className: "flex items-center text-sm font-medium text-blue-600", children: [_jsx(Clock, { className: "w-3 h-3 mr-1" }), location.plannedStart ? format(parseISO(location.plannedStart), 'HH:mm') : '--:--'] }), location.status === 'COMPLETED' && location.actualStart && (_jsxs("div", { className: "text-[10px] text-gray-400 mt-1 italic", children: ["Th\u1EF1c t\u1EBF: ", format(parseISO(location.actualStart), 'HH:mm')] })), ['OWNER', 'MANAGER'].includes(userRole || '') && !isStartPoint && !isEndPoint && (_jsxs("div", { className: "flex gap-1 mt-2", children: [_jsx("button", { onClick: () => onEditLocation?.(location), className: "p-1 text-gray-400 hover:text-blue-600 transition-colors", children: _jsx(Edit2, { className: "w-3.5 h-3.5" }) }), _jsx("button", { onClick: () => handleDeleteLocation(location.id), className: "p-1 text-gray-400 hover:text-red-600 transition-colors", children: _jsx(Trash2, { className: "w-3.5 h-3.5" }) })] }))] })] }), _jsx(TimeVariance, { planned: location.plannedStart || '', actual: location.actualStart || null })] })] }), distanceToNext !== null && travelTimeMinutes !== null && (_jsxs("div", { className: "ml-4 -mt-4 mb-2 flex items-center gap-3", children: [_jsx("div", { className: "w-8 flex justify-center", children: _jsx(Navigation, { className: "w-3 h-3 text-blue-400 rotate-180" }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-[10px] font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded-full border border-blue-100", children: [distanceToNext.toFixed(2), " km"] }), _jsxs("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", children: [_jsx(Clock, { className: "w-2.5 h-2.5" }), "~ ", formatTravelTime(travelTimeMinutes)] })] })] }))] }, location.id));
|
||||
}) })] }, leg.id));
|
||||
})), ['OWNER', 'MANAGER'].includes(userRole || '') && (_jsxs("div", { className: "flex flex-col gap-3 pb-20 mt-8", children: [_jsxs("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", children: [_jsx(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"] }), _jsxs("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", children: [_jsx(Plus, { className: "w-5 h-5" }), " Th\u00EAm ch\u1EB7ng l\u1EBB v\u00E0o cu\u1ED1i"] })] }))] }) }));
|
||||
};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+9
-1
@@ -94,6 +94,7 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
const [viewMode, setViewMode] = useState('timeline');
|
||||
const [isAddLocationOpen, setIsAddLocationOpen] = useState(false);
|
||||
const [targetLegId, setTargetLegId] = useState(null);
|
||||
const [editingLocation, setEditingLocation] = useState(null);
|
||||
const [initialViewState] = useState(() => {
|
||||
const saved = localStorage.getItem('map_view_state');
|
||||
if (saved) {
|
||||
@@ -238,6 +239,12 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
? 'bg-blue-50 text-blue-600 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'}`, children: [_jsx(tab.icon, { className: `w-4 h-4 mr-2 ${activeTab === tab.id ? 'scale-110' : ''}` }), tab.label] }, tab.id))) }), _jsxs("div", { className: "transition-opacity duration-300", children: [activeTab === 'plan' && (_jsxs("div", { className: "animate-in fade-in slide-in-from-bottom-2", children: [_jsx("div", { className: "flex justify-center mb-6", children: _jsxs("div", { className: "bg-gray-100 p-1 rounded-2xl flex gap-1", children: [_jsxs("button", { onClick: () => setViewMode('timeline'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'timeline' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(List, { className: "w-3.5 h-3.5" }), " Danh s\u00E1ch"] }), _jsxs("button", { onClick: () => setViewMode('map'), className: `flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all ${viewMode === 'map' ? 'bg-white shadow-sm text-blue-600' : 'text-gray-500'}`, children: [_jsx(MapIconLucide, { className: "w-3.5 h-3.5" }), " B\u1EA3n \u0111\u1ED3"] })] }) }), viewMode === 'timeline' ? (_jsx(ItineraryTimeline, { onAddLocation: (legId) => {
|
||||
setTargetLegId(legId);
|
||||
setEditingLocation(null);
|
||||
setIsAddLocationOpen(true);
|
||||
}, onEditLocation: (loc) => {
|
||||
setEditingLocation(loc);
|
||||
setTargetLegId(loc.legId);
|
||||
setMapCenter([loc.latitude, loc.longitude]);
|
||||
setIsAddLocationOpen(true);
|
||||
} })) : (_jsxs("div", { className: "h-[60vh] w-full rounded-3xl overflow-hidden shadow-xl border-4 border-white relative", children: [_jsxs(MapContainer, { center: initialViewState?.center || mapCenter, zoom: mapZoom, className: "h-full w-full", preferCanvas: true, children: [_jsx(TileLayer, { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }), _jsx(MapContextMenu, { onAction: handleMapAction }), _jsx(MapTourBounds, { locations: allLocations }), allLocations.length > 1 && (_jsx(Polyline, { positions: allLocations.map(l => [l.latitude, l.longitude]), color: "#3b82f6", weight: 3, dashArray: "5, 10", smoothFactor: 1.5 })), _jsx(MarkerClusterGroup, { chunkedLoading: true, children: legs.flatMap(l => l.locations).map((loc) => {
|
||||
const isStart = startPoint?.id === loc.id;
|
||||
@@ -246,8 +253,9 @@ export const TourDetailPage = ({ onBack }) => {
|
||||
return (_jsx(Marker, { position: [loc.latitude, loc.longitude], icon: icon, children: _jsxs(Popup, { children: [_jsx("div", { className: "font-bold", children: loc.name }), _jsx("div", { className: "text-xs text-gray-500", children: loc.type })] }) }, loc.id));
|
||||
}) })] }), _jsx("div", { className: "absolute top-4 left-4 z-[1000] bg-white/90 backdrop-blur-md p-3 rounded-2xl text-[10px] font-bold text-gray-500 shadow-lg border border-white", children: "M\u1EB9o: Nh\u1EA5n gi\u1EEF (Mobile) ho\u1EB7c Chu\u1ED9t ph\u1EA3i \u0111\u1EC3 ghim \u0111\u1ECBa \u0111i\u1EC3m" })] }))] })), activeTab === 'expense' && (_jsx("div", { className: "animate-in fade-in slide-in-from-bottom-4", children: _jsx(ExpenseManager, {}) })), activeTab === 'photo' && (_jsx("div", { className: "grid grid-cols-3 gap-1.5 animate-in fade-in", children: [1, 2, 3, 4, 5, 6].map((i) => (_jsxs("div", { className: "aspect-square bg-gray-200 rounded-xl overflow-hidden relative group border border-white", children: [_jsx("div", { className: "absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" }), _jsx("img", { src: `https://picsum.photos/seed/${i + 10}/400/400`, alt: "Tour photo", className: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" })] }, i))) })), activeTab === 'settings' && (_jsxs("div", { className: "p-8 text-center bg-white rounded-3xl border border-dashed border-gray-200 animate-in zoom-in-95", children: [_jsx(Settings, { className: "w-12 h-12 text-gray-300 mx-auto mb-4" }), _jsx("p", { className: "text-gray-500 font-medium", children: "T\u00EDnh n\u0103ng qu\u1EA3n l\u00FD th\u00E0nh vi\u00EAn \u0111ang \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt..." })] }))] })] }), _jsx("div", { className: "fixed bottom-6 left-1/2 -translate-x-1/2 z-40", children: _jsx("button", { onClick: () => {
|
||||
setTargetLegId(null);
|
||||
setEditingLocation(null);
|
||||
if (activeTab === 'plan')
|
||||
setIsAddLocationOpen(true);
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) }), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, tourId: currentTour.id }))] }));
|
||||
}, className: "bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-all flex items-center font-bold", children: activeTab === 'plan' ? 'Thêm địa điểm' : activeTab === 'expense' ? 'Thêm chi phí' : 'Đăng ảnh' }) }), currentTour && (_jsx(AddLocationModal, { isOpen: isAddLocationOpen, onClose: () => setIsAddLocationOpen(false), initialLegId: targetLegId || undefined, editingLocation: editingLocation, tourId: currentTour.id }))] }));
|
||||
};
|
||||
//# sourceMappingURL=TourDetailPage.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+45
-1
@@ -391,6 +391,50 @@ TourController = __decorate([
|
||||
Controller('v1/tours'),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
], TourController);
|
||||
let LocationController = class LocationController {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async updateLocation(id, body) {
|
||||
return this.prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
address: body.address,
|
||||
latitude: body.latitude,
|
||||
longitude: body.longitude,
|
||||
type: body.type,
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : undefined,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : undefined,
|
||||
status: body.status,
|
||||
}
|
||||
});
|
||||
}
|
||||
async deleteLocation(id) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
Patch(':id'),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__param(1, Body()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], LocationController.prototype, "updateLocation", null);
|
||||
__decorate([
|
||||
Delete(':id'),
|
||||
__param(0, Param('id', ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], LocationController.prototype, "deleteLocation", null);
|
||||
LocationController = __decorate([
|
||||
Controller('v1/locations'),
|
||||
UseGuards(JwtAuthGuard),
|
||||
__metadata("design:paramtypes", [PrismaService])
|
||||
], LocationController);
|
||||
let LegController = class LegController {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
@@ -620,7 +664,7 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||
providers: [PrismaService, JwtStrategy],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -15,6 +15,8 @@ interface TourState {
|
||||
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||
deleteLeg: (legId: string) => Promise<void>;
|
||||
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||
updateLocation: (locationId: string, data: any) => Promise<void>;
|
||||
deleteLocation: (locationId: string) => Promise<void>;
|
||||
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||
optimizeRouting: (legId: string) => Promise<void>;
|
||||
|
||||
Vendored
+30
@@ -145,6 +145,36 @@ export const useTourStore = create((set, get) => ({
|
||||
throw new Error('Lỗi khi thêm địa điểm');
|
||||
get().fetchTour(tourId);
|
||||
},
|
||||
updateLocation: async (locationId, data) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Lỗi khi cập nhật địa điểm');
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
},
|
||||
deleteLocation: async (locationId) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Lỗi khi xóa địa điểm');
|
||||
const { currentTour } = get();
|
||||
if (currentTour)
|
||||
get().fetchTour(currentTour.id);
|
||||
},
|
||||
updateTourStartPoint: async (tourId, data) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -333,6 +333,35 @@ class TourController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('v1/locations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class LocationController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLocation(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
|
||||
return this.prisma.location.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
address: body.address,
|
||||
latitude: body.latitude,
|
||||
longitude: body.longitude,
|
||||
type: body.type,
|
||||
plannedStart: body.plannedStart ? new Date(body.plannedStart) : undefined,
|
||||
plannedEnd: body.plannedEnd ? new Date(body.plannedEnd) : undefined,
|
||||
status: body.status,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLocation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.prisma.location.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('v1/legs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
class LegController {
|
||||
@@ -546,7 +575,7 @@ class UserController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController],
|
||||
controllers: [AppController, AuthController, TourController, UserController, RoutingController, LegController, LocationController],
|
||||
providers: [PrismaService, JwtStrategy],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ interface TourState {
|
||||
updateLeg: (legId: string, data: any) => Promise<void>;
|
||||
deleteLeg: (legId: string) => Promise<void>;
|
||||
addLocation: (tourId: string, locationData: any) => Promise<void>;
|
||||
updateLocation: (locationId: string, data: any) => Promise<void>;
|
||||
deleteLocation: (locationId: string) => Promise<void>;
|
||||
updateTourStartPoint: (tourId: string, data: any) => Promise<void>;
|
||||
updateTourEndPoint: (tourId: string, data: any) => Promise<void>;
|
||||
optimizeRouting: (legId: string) => Promise<void>;
|
||||
@@ -174,6 +176,34 @@ export const useTourStore = create<TourState>((set, get) => ({
|
||||
// Làm mới dữ liệu tour hiện tại
|
||||
get().fetchTour(tourId);
|
||||
},
|
||||
updateLocation: async (locationId: string, data: any) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Lỗi khi cập nhật địa điểm');
|
||||
|
||||
const { currentTour } = get();
|
||||
if (currentTour) get().fetchTour(currentTour.id);
|
||||
},
|
||||
deleteLocation: async (locationId: string) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/locations/${locationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error('Lỗi khi xóa địa điểm');
|
||||
|
||||
const { currentTour } = get();
|
||||
if (currentTour) get().fetchTour(currentTour.id);
|
||||
},
|
||||
updateTourStartPoint: async (tourId: string, data: any) => {
|
||||
const API_BASE = `http://${window.location.hostname}:3001`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/tours/${tourId}/start-point`, {
|
||||
|
||||
Reference in New Issue
Block a user