705 lines
40 KiB
TypeScript
705 lines
40 KiB
TypeScript
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, ChevronDown } from 'lucide-react';
|
|
import { useTourStore } from '@/store/useTourStore';
|
|
import { useConfirm } from '@/hooks/useConfirm';
|
|
import { useNotification } from '@/hooks/useNotification';
|
|
import { CommentModal } from '@/components/CommentModal';
|
|
|
|
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,
|
|
onQuickNote,
|
|
onNavigate,
|
|
onSuccess,
|
|
isPublicView = false
|
|
}: {
|
|
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
|
onEditLocation?: (location: any) => void,
|
|
onQuickNote?: (name: string) => void,
|
|
onNavigate?: (location: any) => void,
|
|
onSuccess?: () => void,
|
|
isPublicView?: boolean
|
|
}) => {
|
|
// 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 addLeg = useTourStore(state => state.addLeg);
|
|
const updateLeg = useTourStore(state => state.updateLeg);
|
|
// Removed: const { isPublicView } = useTourStore(state => state); // isPublicView is passed as a prop
|
|
const deleteLeg = useTourStore(state => state.deleteLeg);
|
|
const initializeLegs = useTourStore(state => state.initializeLegs);
|
|
const deleteLocation = useTourStore(state => state.deleteLocation);
|
|
|
|
// Khai báo logic canEdit để sử dụng trong toàn bộ component
|
|
const canEdit = isPublicView ? false : ['OWNER', 'MANAGER'].includes(userRole || '');
|
|
|
|
const confirm = useConfirm();
|
|
const notify = useNotification();
|
|
const [isLegCountModalOpen, setIsLegCountModalOpen] = useState(false);
|
|
const [tempLegCount, setTempLegCount] = useState(3);
|
|
const [isCommentModalOpen, setIsCommentModalOpen] = useState(false);
|
|
const [commentLocationId, setCommentLocationId] = useState('');
|
|
const [commentLocationName, setCommentLocationName] = useState('');
|
|
|
|
// State to track single expanded stage (exclusive single-expansion mode)
|
|
const [expandedStageId, setExpandedStageId] = useState<string | null>(legs.length > 0 ? legs[0]?.id : null);
|
|
|
|
const toggleStageExpanded = (legId: string) => {
|
|
// Exclusive mode: if clicking the same stage, close it. Otherwise, open only the clicked one.
|
|
setExpandedStageId(prevId => prevId === legId ? null : legId);
|
|
};
|
|
|
|
// Tối ưu hóa: Cập nhật UI ngay lập tức bằng cách can thiệp vào State của Store
|
|
const handleCommentIncrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map((leg: any) => ({
|
|
...leg,
|
|
locations: leg.locations.map((loc: any) =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: (loc._count?.comments || 0) + 1 } }
|
|
: loc
|
|
)
|
|
}));
|
|
// Dùng setState của Zustand để cập nhật một phần dữ liệu
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
const handleCommentDecrement = (locationId: string) => {
|
|
const currentLegs = useTourStore.getState().legs;
|
|
const updatedLegs = currentLegs.map((leg: any) => ({
|
|
...leg,
|
|
locations: leg.locations.map((loc: any) =>
|
|
loc.id === locationId
|
|
? { ...loc, _count: { ...loc._count, comments: Math.max(0, (loc._count?.comments || 1) - 1) } }
|
|
: loc
|
|
)
|
|
}));
|
|
useTourStore.setState({ legs: updatedLegs });
|
|
};
|
|
|
|
// State cho Modal sửa chặng
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [editingLegData, setEditingLegData] = useState({
|
|
id: '',
|
|
note: '',
|
|
description: '',
|
|
startDate: '',
|
|
endDate: ''
|
|
});
|
|
|
|
const handleStatusClick = (location: any) => {
|
|
if (onNavigate) {
|
|
onNavigate(location);
|
|
}
|
|
};
|
|
|
|
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 () => {
|
|
setTempLegCount(legs.length > 0 ? legs.length : 3);
|
|
setIsLegCountModalOpen(true);
|
|
};
|
|
|
|
const confirmDeclareLegs = async () => {
|
|
if (tempLegCount > 0 && tempLegCount <= 20 && currentTour) {
|
|
await initializeLegs(currentTour.id, tempLegCount);
|
|
}
|
|
setIsLegCountModalOpen(false);
|
|
};
|
|
|
|
const handleEditLeg = async (leg: any) => {
|
|
setEditingLegData({
|
|
id: leg.id,
|
|
note: leg.note || "",
|
|
description: leg.description || "",
|
|
startDate: leg.startDate ? leg.startDate.split('T')[0] : "",
|
|
endDate: leg.endDate ? leg.endDate.split('T')[0] : ""
|
|
});
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const saveLegEdit = async () => {
|
|
if (editingLegData.id) {
|
|
await updateLeg(editingLegData.id, {
|
|
note: editingLegData.note,
|
|
description: editingLegData.description,
|
|
startDate: editingLegData.startDate || null,
|
|
endDate: editingLegData.endDate || null
|
|
});
|
|
setIsEditModalOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteLeg = async (legId: string) => {
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa chặng',
|
|
message: 'Bạn có chắc chắn muốn xóa chặng này?'
|
|
});
|
|
if (isConfirmed) {
|
|
try {
|
|
await deleteLeg(legId);
|
|
onSuccess?.();
|
|
} catch (err: any) {
|
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDeleteLocation = async (id: string) => {
|
|
const isConfirmed = await confirm({
|
|
title: 'Xóa địa điểm',
|
|
message: 'Bạn có chắc chắn muốn xóa địa điểm này?'
|
|
});
|
|
if (isConfirmed) {
|
|
try {
|
|
await deleteLocation(id);
|
|
onSuccess?.();
|
|
} catch (err: any) {
|
|
notify({ title: 'Lỗi', message: err.message, type: 'error' });
|
|
}
|
|
}
|
|
};
|
|
|
|
// Tạo mảng phẳng tất cả địa điểm để tính toán quãng đường liên tục giữa các chặng
|
|
const allLocations = useMemo(() => legs.flatMap(l => l.locations), [legs]);
|
|
|
|
useEffect(() => {
|
|
console.log("ItineraryTimeline: Legs updated", legs);
|
|
// Initialize first leg as expanded when legs change
|
|
if (legs.length > 0 && !expandedStageId) {
|
|
setExpandedStageId(legs[0].id);
|
|
}
|
|
}, [legs]);
|
|
|
|
return (
|
|
<div id="itinerary-timeline-print-zone" className="timeline-scroll-container itinerary-timeline-container">
|
|
{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 (
|
|
<section key={leg.id} className={`folder-node-wrapper ${expandedStageId === leg.id ? 'expanded' : 'collapsed'}`}>
|
|
{/* Folder header row - clickable to toggle exclusive expansion */}
|
|
<div
|
|
onClick={() => toggleStageExpanded(leg.id)}
|
|
className="folder-header-row animate-in fade-in slide-in-from-bottom-4 duration-300 hover:bg-gray-50/50 transition-colors"
|
|
>
|
|
<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 shrink-0">
|
|
{leg.sequence}
|
|
</span>
|
|
<div className="flex flex-col overflow-hidden">
|
|
<span className="truncate leading-tight">{leg.note || `Chi tiết Chặng ${leg.sequence}`}</span>
|
|
{leg.startDate && (
|
|
<span className="text-[10px] text-gray-400 font-black uppercase tracking-wider flex items-center gap-1 mt-0.5">
|
|
<CalendarIcon className="w-2.5 h-2.5" />
|
|
{format(parseISO(leg.startDate), 'dd/MM/yyyy')}
|
|
{leg.endDate && leg.endDate !== leg.startDate && ` - ${format(parseISO(leg.endDate), 'dd/MM/yyyy')}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</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>
|
|
{/* 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 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
|
|
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 bg-gray-50 text-gray-600 hover:text-blue-600 hover:bg-blue-100 rounded-xl transition-all border border-gray-200"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteLeg(leg.id); }}
|
|
className="p-2 bg-gray-50 text-red-600 hover:text-white-600 hover:bg-red-100 rounded-xl transition-all border border-gray-200"
|
|
>
|
|
<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>
|
|
<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="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 */}
|
|
<div className="child-nodes-list-wrapper">
|
|
{/* Folder child content box - Grid accordion for exclusive expansion */}
|
|
<div className={`folder-child-content-box ${expandedStageId === leg.id ? 'expanded' : ''}`}>
|
|
<div className="child-nodes-list relative">
|
|
{/* Vertical Line for the whole leg - Dynamic height */}
|
|
<div className="absolute left-6 top-16 w-0.5 bg-blue-100 -z-0 h-full" />
|
|
|
|
<div className="ml-2">
|
|
{/* Nút thêm nhanh "Điểm xuất phát" cho Chặng 1 nếu chưa có */}
|
|
{legIdx === 0 && !leg.locations.some((loc: any) => loc.plannedStart && new Date(loc.plannedStart).getTime() === 0) && (
|
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
|
<div className="z-10 mt-1.5 mr-4">
|
|
<div className="w-8 h-8 bg-white rounded-full border-2 border-dashed border-blue-200 flex items-center justify-center text-blue-400">
|
|
<MapPin className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => onAddLocation?.(leg.id, true)}
|
|
className="flex-1 bg-blue-50/20 dark:bg-slate-800/20 p-4 rounded-xl border border-dashed border-blue-100 dark:border-slate-700 hover:border-blue-400 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-slate-800/40 transition-all flex items-center justify-between group"
|
|
>
|
|
<div className="text-left">
|
|
<span className="inline-block px-2 py-0.5 bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm xuất phát</span>
|
|
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn để ghim điểm bắt đầu cho Tour...</h3>
|
|
</div>
|
|
<Plus className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Nút thêm nhanh "Điểm kết thúc" cho Chặng cuối nếu chưa có */}
|
|
{legIdx === legs.length - 1 && !legs.some((l: any) => l.locations.some((loc: any) => loc.plannedEnd && new Date(loc.plannedEnd).getTime() === 0)) && (
|
|
<div className="relative flex group mb-6 opacity-80 hover:opacity-100 transition-opacity">
|
|
<div className="z-10 mt-1.5 mr-4">
|
|
<div className="w-8 h-8 bg-white dark:bg-slate-800 rounded-full border-2 border-dashed border-red-200 dark:border-red-700 flex items-center justify-center text-red-400">
|
|
<Flag className="w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => onAddLocation?.(leg.id, false, true)}
|
|
className="flex-1 bg-red-50/20 dark:bg-red-900/20 p-4 rounded-xl border border-dashed border-red-100 dark:border-red-700 hover:border-red-400 dark:hover:border-red-400 hover:bg-red-50 dark:hover:bg-red-900/40 transition-all flex items-center justify-between group"
|
|
>
|
|
<div className="text-left">
|
|
<span className="inline-block px-2 py-0.5 bg-red-50 dark:bg-red-700 text-red-600 dark:text-red-300 text-[10px] font-bold rounded-md mb-1 uppercase tracking-wider">Điểm kết thúc</span>
|
|
<h3 className="font-bold text-gray-400 dark:text-slate-400 text-sm italic">Nhấn để ghim điểm kết thúc cho Tour...</h3>
|
|
</div>
|
|
<Plus className="w-5 h-5 text-red-500 group-hover:scale-110 transition-transform" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{leg.locations.map((location: any) => {
|
|
// Tìm vị trí của điểm này trong toàn bộ hành trình
|
|
const globalIdx = allLocations.findIndex((loc: any) => loc.id === location.id);
|
|
const prevLocation = globalIdx > 0 ? allLocations[globalIdx - 1] : null;
|
|
|
|
const distanceFromPrev = prevLocation
|
|
? calculateDistance(prevLocation.latitude, prevLocation.longitude, location.latitude, location.longitude)
|
|
: null;
|
|
|
|
const locationExpense = leg.expenses?.find((e: any) => e.locationId === location.id);
|
|
|
|
// Nhận diện điểm mốc dựa trên timestamp đặc biệt (0) thay vì chỉ số mảng
|
|
const isStartPoint = location.plannedStart && new Date(location.plannedStart).getTime() === 0;
|
|
const isEndPoint = location.plannedEnd && new Date(location.plannedEnd).getTime() === 0;
|
|
|
|
const plannedTimeStr = isStartPoint ? location.plannedEnd : location.plannedStart;
|
|
const hasValidPlannedTime = plannedTimeStr && new Date(plannedTimeStr).getTime() !== 0;
|
|
|
|
const dwellMinutes = (location.plannedStart && location.plannedEnd && !isStartPoint && !isEndPoint)
|
|
? differenceInMinutes(parseISO(location.plannedEnd), parseISO(location.plannedStart))
|
|
: null;
|
|
|
|
return (
|
|
<div key={location.id}>
|
|
<div className="relative flex group mb-6">
|
|
{/* Timeline Node */}
|
|
<div className="z-10 -ml-3.5 mr-1.5 mt-1.5">
|
|
<button
|
|
onClick={() => handleStatusClick(location)}
|
|
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
|
|
onClick={() => onNavigate?.(location)}
|
|
className={`flex-1 bg-white p-4 rounded-xl border transition-all duration-200 cursor-pointer ${
|
|
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
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onNavigate?.(location);
|
|
}}
|
|
className={`font-semibold text-lg cursor-pointer hover:text-blue-600 transition-colors select-none ${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()}đ</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" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex gap-1 mb-2">
|
|
{onQuickNote && !isPublicView && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onQuickNote(location.name);
|
|
}}
|
|
className="flex items-center gap-1 px-2 py-1 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-bold transition-all border border-amber-100"
|
|
title="Ghi chú nhanh"
|
|
>
|
|
<FileText className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setCommentLocationId(location.id);
|
|
setCommentLocationName(location.name);
|
|
setIsCommentModalOpen(true);
|
|
}}
|
|
className="flex items-center gap-1 px-2 py-1 bg-gray-50 hover:bg-blue-50 text-gray-400 hover:text-blue-600 rounded-lg text-[10px] font-bold transition-all border border-gray-100 hover:border-blue-100"
|
|
>
|
|
<MessageSquare className="w-3 h-3" />
|
|
{location._count?.comments > 0 && `(${location._count.comments})`}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center text-sm font-black text-blue-600">
|
|
<Clock className="w-3 h-3 mr-1" />
|
|
{hasValidPlannedTime ? format(parseISO(plannedTimeStr), '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>
|
|
)}
|
|
{canEdit && ( // Allow editing and deleting of all locations if user has edit permissions
|
|
<div className="flex gap-1 mt-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
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={(e) => {
|
|
e.stopPropagation();
|
|
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={hasValidPlannedTime ? plannedTimeStr : ''} actual={location.actualStart || null} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hiển thị quãng đường di chuyển từ điểm trước ĐẾN điểm hiện tại */}
|
|
{distanceFromPrev !== null && distanceFromPrev > 0 && (
|
|
<div className="ml-14 -mt-4 mb-6 flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-500">
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100 shadow-sm">
|
|
<Navigation className="w-3 h-3 rotate-45" />
|
|
<span className="text-[10px] font-black uppercase tracking-tighter">
|
|
+{distanceFromPrev.toFixed(1)} km từ {prevLocation?.name.split(',')[0]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div> {/* End of ml-2 wrapper */}
|
|
</div> {/* End of child-nodes-list */}
|
|
</div> {/* End of folder-child-content-box */}
|
|
</div> {/* End of child-nodes-list-wrapper */}
|
|
</section>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{/* Actions at the bottom of the list */}
|
|
{canEdit && ( // Only show these buttons if canEdit
|
|
<div className="flex flex-col gap-3 pb-20 mt-8">
|
|
<button
|
|
onClick={handleDeclareLegs}
|
|
className="w-full py-4 rounded-2xl bg-white dark:bg-slate-800 text-blue-600 dark:text-blue-300 border-2 border-dashed border-blue-200 dark:border-slate-700 hover:bg-blue-50 dark:hover:bg-slate-700 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 dark:bg-blue-700 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>
|
|
)}
|
|
|
|
{/* Modal Khai báo số chặng (Popover) */}
|
|
{isLegCountModalOpen && (
|
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsLegCountModalOpen(false)} />
|
|
<div className="relative w-full max-w-sm bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-black text-gray-900 dark:text-white">Số chặng lộ trình</h3>
|
|
<button onClick={() => setIsLegCountModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
|
|
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-500 dark:text-slate-400 mb-6 leading-relaxed">
|
|
Bạn muốn chia chuyến đi này thành bao nhiêu chặng nhỏ? (Tối đa 20 chặng)
|
|
</p>
|
|
|
|
<div className="flex items-center justify-center gap-6 mb-8">
|
|
<button
|
|
onClick={() => setTempLegCount(Math.max(1, tempLegCount - 1))}
|
|
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
|
|
>
|
|
-
|
|
</button>
|
|
<span className="text-4xl font-black text-blue-600 dark:text-blue-400 w-12 text-center">{tempLegCount}</span>
|
|
<button
|
|
onClick={() => setTempLegCount(Math.min(20, tempLegCount + 1))}
|
|
className="w-12 h-12 rounded-2xl border-2 border-gray-100 dark:border-slate-700 flex items-center justify-center text-2xl font-bold text-gray-400 dark:text-slate-400 hover:border-blue-200 dark:hover:border-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-all"
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={confirmDeclareLegs}
|
|
className="w-full py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
|
|
>
|
|
Xác nhận
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal Chỉnh sửa Chặng (Popover) */}
|
|
{isEditModalOpen && (
|
|
<div className="fixed inset-0 z-[4000] flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-gray-900/60 dark:bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsEditModalOpen(false)} />
|
|
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 rounded-[32px] shadow-2xl p-8 animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h3 className="text-xl font-black text-gray-900 dark:text-white">Chỉnh sửa Chặng</h3>
|
|
<button onClick={() => setIsEditModalOpen(false)} className="p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-full transition-colors">
|
|
<X className="w-5 h-5 text-gray-400 dark:text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Tên chặng</label>
|
|
<input
|
|
type="text"
|
|
value={editingLegData.note}
|
|
onChange={(e) => setEditingLegData({ ...editingLegData, note: e.target.value })}
|
|
placeholder="VD: Ngày 1: Khởi hành"
|
|
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
|
|
<AlignLeft className="w-3 h-3" /> Mô tả chi tiết
|
|
</label>
|
|
<textarea
|
|
value={editingLegData.description}
|
|
onChange={(e) => setEditingLegData({ ...editingLegData, description: e.target.value })}
|
|
placeholder="Mô tả các hoạt động chính trong chặng này..."
|
|
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm min-h-[120px] resize-none text-gray-800 dark:text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="flex items-center gap-2 text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">
|
|
<CalendarIcon className="w-3 h-3" /> Bắt đầu
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={editingLegData.startDate}
|
|
onChange={(e) => setEditingLegData({ ...editingLegData, startDate: e.target.value })}
|
|
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-black text-gray-400 dark:text-slate-400 uppercase tracking-widest mb-2 ml-1">Kết thúc</label>
|
|
<input
|
|
type="date"
|
|
value={editingLegData.endDate}
|
|
onChange={(e) => setEditingLegData({ ...editingLegData, endDate: e.target.value })}
|
|
className="w-full px-5 py-4 bg-gray-50 dark:bg-slate-800 border border-gray-100 dark:border-slate-700 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm text-gray-800 dark:text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-8">
|
|
<button
|
|
onClick={() => setIsEditModalOpen(false)}
|
|
className="py-4 bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-300 font-bold rounded-2xl transition-all active:scale-95"
|
|
>
|
|
Hủy
|
|
</button>
|
|
<button
|
|
onClick={saveLegEdit}
|
|
className="py-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-800 text-white font-bold rounded-2xl shadow-lg shadow-blue-100 dark:shadow-blue-950 transition-all active:scale-95"
|
|
>
|
|
Lưu thay đổi
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<CommentModal
|
|
isOpen={isCommentModalOpen}
|
|
onClose={() => setIsCommentModalOpen(false)}
|
|
locationId={commentLocationId}
|
|
locationName={commentLocationName}
|
|
isPublicView={isPublicView}
|
|
onCommentAdded={() => handleCommentIncrement(commentLocationId)}
|
|
onCommentDeleted={() => handleCommentDecrement(commentLocationId)}
|
|
/>
|
|
</div>
|
|
);
|
|
}; |