fix: lỗi hiển thị trong itineraryTimeline

This commit is contained in:
2026-06-23 11:40:41 +07:00
parent a65be48d36
commit 6b15e7ff02
17 changed files with 625 additions and 135 deletions
+37 -17
View File
@@ -75,6 +75,14 @@ export const ItineraryTimeline = ({
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) => {
@@ -197,11 +205,14 @@ export const ItineraryTimeline = ({
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="max-w-2xl mx-auto p-0 bg-gray-50 min-h-screen">
<div className="px-2 pt-4">
<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" />
@@ -220,9 +231,12 @@ export const ItineraryTimeline = ({
const prevLegLastLoc = legIdx > 0 ? legs[legIdx - 1]?.locations.slice(-1)[0] : null;
return (
<div key={leg.id} className="relative mb-12 animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Leg Header */}
<div className="sticky top-[136px] z-20 bg-gray-50/90 backdrop-blur-sm flex items-center mb-6 py-2 px-2">
<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">
@@ -245,24 +259,24 @@ export const ItineraryTimeline = ({
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<div className="flex items-center gap-2 ml-4" onClick={(e) => e.stopPropagation()}>
{canEdit && (
<>
<button
onClick={() => onAddLocation?.(leg.id, legIdx === 0 && leg.locations.length === 0)}
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={() => handleEditLeg(leg)}
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={() => handleDeleteLeg(leg.id)}
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" />
@@ -298,12 +312,16 @@ export const ItineraryTimeline = ({
)} {/* Only show optimize button if canEdit */}
</div>
{/* Vertical Line for the whole leg */}
{/* Mở rộng đường kẻ xuống dưới (bottom-[-3rem]) để nối liền với chặng tiếp theo */}
<div className={`absolute left-6 top-16 ${legIdx === legs.length - 1 ? 'bottom-0' : 'bottom-[-3.5rem]'} w-0.5 bg-blue-100 -z-0`} />
{/* 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ó */}
<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">
@@ -522,8 +540,11 @@ export const ItineraryTimeline = ({
</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>
);
})
)}
@@ -546,7 +567,6 @@ export const ItineraryTimeline = ({
</button>
</div>
)}
</div>
{/* Modal Khai báo số chặng (Popover) */}
{isLegCountModalOpen && (
+23 -19
View File
@@ -5,9 +5,10 @@ import { useNotification } from '@/hooks/useNotification';
interface TourChatProps {
tourId: string;
embedded?: boolean;
}
export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
export const TourChat: React.FC<TourChatProps> = ({ tourId, embedded = false }) => {
const notify = useNotification();
const [messages, setMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
@@ -416,21 +417,24 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
}
};
// currentUserId is defined at the top
return (
<div className="bg-white border border-gray-150 rounded-2xl shadow-lg overflow-hidden flex flex-col h-[500px]">
{/* Chat Header */}
<div className="p-4 border-b border-gray-150 flex items-center gap-2 bg-gray-50/50">
<MessageSquare className="w-5 h-5 text-blue-500" />
<div>
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đi thông tin, hình nh đnh vị giữa các thành viên</p>
</div>
<div className={`chat-viewport-wrapper flex flex-col !overflow-hidden !w-full ${embedded ? 'h-full flex-1' : 'h-[100dvh]'} bg-white`}>
{/* Fixed Top Layout Block - Header and Tabs Container */}
<div className="fixed-top-layout-block flex-shrink-0 !w-full z-50 bg-white border-b border-gray-200">
{/* Chat Header - Only show when not embedded */}
{!embedded && (
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-500" />
<div>
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đi thông tin, hình nh đnh vị giữa các thành viên</p>
</div>
</div>
)}
</div>
{/* Messages list */}
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 min-h-0 bg-slate-50/20">
{/* Chat History Scroll Viewport - ONLY scrollable area */}
<div className="chat-history-scroll-viewport flex-1 min-h-0 !overflow-y-auto !overflow-x-hidden p-4 flex flex-col gap-3 bg-slate-50/20">
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
@@ -468,7 +472,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
isMe
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white text-gray-700 rounded-tl-none border border-gray-150 shadow-sm'
: 'bg-white text-gray-700 rounded-tl-none border border-gray-200 shadow-sm'
}`}>
{/* Attachment Image */}
{msg.attachmentUrl && (
@@ -498,7 +502,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
isMe
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
: 'bg-gray-100 border-gray-200 text-gray-750 hover:bg-gray-200'
: 'bg-gray-100 border-gray-200 text-gray-700 hover:bg-gray-200'
}`}
>
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
@@ -525,7 +529,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
{/* Previews (Image & GPS Location) */}
{(imagePreview || attachedLocation) && (
<div className="px-4 py-2 border-t border-gray-150 bg-gray-50/80 flex flex-wrap gap-2">
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50/80 flex flex-wrap gap-2 !flex-shrink-0">
{imagePreview && (
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
@@ -557,8 +561,8 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
</div>
)}
{/* Chat Input wrapper */}
<div className="relative">
{/* Locked Chat Input Footer */}
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
{/* Mention list dropdown */}
{showMentionList && filteredParticipants.length > 0 && (
<div
@@ -589,7 +593,7 @@ export const TourChat: React.FC<TourChatProps> = ({ tourId }) => {
{/* Chat Input form */}
<form
onSubmit={handleSendMessage}
className="p-3 border-t border-gray-150 bg-gray-50 flex gap-2 items-center"
className="p-3 flex gap-2 items-center !w-full relative"
>
<input
type="file"
+289
View File
@@ -1,10 +1,299 @@
@import "tailwindcss";
@layer base {
:root {
--main-header-height: 56px;
--sub-nav-height: 48px;
--combined-top-height: 104px;
}
html, body, #root, .app-container {
height: 100%;
margin: 0;
padding: 0;
max-width: 100vw !important;
overflow-x: hidden !important;
}
/* Fix viewport overflow on all container levels */
body, html {
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.itinerary-timeline-page {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.main-top-bar {
position: sticky;
top: 0;
height: var(--main-header-height);
z-index: 50;
background-color: #ffffff;
}
.sub-nav-menu {
position: sticky;
top: var(--main-header-height);
height: var(--sub-nav-height);
z-index: 40;
width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
border-bottom: 1px solid #e5e7eb;
background-color: #ffffff;
}
/* Master viewport wrapper - contains entire itinerary */
.itinerary-viewport {
display: flex !important;
flex-direction: column !important;
height: 100dvh !important;
width: 100vw !important;
overflow: hidden !important;
}
.itinerary-viewport-wrapper {
display: flex !important;
flex-direction: column !important;
height: 100dvh !important;
width: 100vw !important;
overflow: hidden !important;
}
/* Global sticky header zone - locks main header + sub-nav at top */
.global-sticky-header-zone {
position: sticky !important;
top: 0;
z-index: 100 !important;
background-color: #ffffff;
flex-shrink: 0 !important;
}
/* Fixed header block (handled at parent TourDetailPage level) */
.fixed-top-header-block {
flex-shrink: 0 !important;
width: 100% !important;
background-color: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
/* Timeline scroll container - main scrollable region */
.directory-scroll-viewport {
flex: 1 !important;
overflow-y: auto !important;
position: relative !important;
z-index: 10 !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
background-color: #f8fafc;
}
.timeline-scroll-container {
flex: 1 !important;
overflow-y: auto !important;
position: relative !important;
z-index: 10 !important;
width: 100% !important;
padding: 0 !important;
}
/* Scrollable timeline area - removes all padding gaps */
.timeline-scroll-area {
flex: 1 1 0% !important;
overflow-y: auto !important;
width: 100% !important;
padding: 0 !important;
margin-top: 0px !important;
background-color: #f8fafc;
}
.timeline-scroll-viewport {
flex: 1;
overflow-y: auto;
width: 100%;
}
/* Main timeline container with dynamic height support */
.itinerary-timeline-container {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100vw !important;
height: auto !important;
overflow-x: hidden !important;
position: relative !important;
}
/* Individual folder node wrapper - base stage container */
.folder-node-wrapper {
width: 100% !important;
background-color: #ffffff;
margin-bottom: 1px !important;
display: flex;
flex-direction: column;
}
/* Individual stage card block - backwards compatibility */
.stage-card-block {
width: 100% !important;
background-color: #ffffff;
margin-bottom: 1px !important;
display: flex;
flex-direction: column;
}
/* Stage section - semantic wrapper */
.stage-section {
position: relative;
width: 100%;
display: block !important;
margin: 0 !important;
padding: 0 !important;
}
/* Folder header row - sticky positioning for pinned effect */
.folder-header-row {
position: sticky !important;
top: 0px !important;
z-index: 30 !important;
width: 100% !important;
padding: 12px 16px !important;
display: flex;
align-items: center;
background-color: #ffffff !important;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.2s;
}
.folder-header-row:hover {
background-color: #f9fafb !important;
}
/* Stage header row - sticky positioning for pinned effect */
.stage-header-row {
position: sticky !important;
top: 0px !important;
z-index: 40 !important;
width: 100% !important;
padding: 12px 16px !important;
display: flex;
align-items: center;
background-color: #ffffff !important;
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.2s;
}
.stage-header-row:hover {
background-color: #f9fafb !important;
}
/* Alternate sticky header styling (kept for backwards compatibility) */
.stage-sticky-header {
position: sticky;
top: 0;
z-index: 30;
background-color: #ffffff;
width: 100%;
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
display: flex;
align-items: center;
cursor: pointer;
}
/* Folder child content box - Grid accordion for exclusive expansion */
.folder-child-content-box {
display: grid !important;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
width: 100%;
}
/* Expanded folder state - children visible */
.folder-child-content-box.expanded {
grid-template-rows: 1fr !important;
}
/* Wrapper for child nodes list - proper z-index layering */
.child-nodes-list-wrapper {
position: relative;
z-index: 20 !important;
width: 100%;
}
/* Child nodes list - content container within expanded folder */
.child-nodes-list {
overflow: hidden;
min-height: 0px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* CSS Grid Accordion Engine - Smooth expand/collapse without layout shattering */
.stage-accordion-content {
display: grid !important;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease-out !important;
width: 100%;
}
/* Expanded state - opens to full content height */
.stage-accordion-content.expanded {
grid-template-rows: 1fr !important;
}
/* Scrollable content body - lower z-index so it scrolls behind header */
.stage-scrollable-content-body {
position: relative;
z-index: 20 !important;
width: 100%;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Inner content wrapper - structural compliance */
.stage-points-list {
overflow: hidden;
min-height: 0px;
padding: 0 16px 16px 16px;
}
/* Legacy accordion container classes (for backwards compatibility) */
.stage-accordion-transition-container {
display: grid !important;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
width: 100% !important;
}
.stage-accordion-transition-container.expanded {
grid-template-rows: 1fr !important;
}
.stage-accordion-inner-content {
overflow: hidden !important;
min-height: 0px !important;
width: 100% !important;
display: flex;
flex-direction: column;
}
.stage-content-body {
width: 100%;
padding: 16px;
}
}
+3 -3
View File
@@ -1681,9 +1681,9 @@ const renderInitialsAvatar = (name: string, sizeClass = 'w-10 h-10 text-sm') =>
</div>
)}
{/* TAB 4: REALTIME CHAT */}
{activeTab === 'chats' && (
<div className="flex-1 w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* TAB 4: REALTIME CHAT */}
{activeTab === 'chats' && (
<div className="flex flex-col h-[100dvh] md:h-auto w-full bg-slate-900/40 border-0 md:border border-slate-800/80 md:rounded-3xl overflow-hidden flex animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Chats List sidebar: show if not mobile OR if mobile and no active chat user */}
{(!isMobile || !activeChatUser) && (
+14 -5
View File
@@ -2003,9 +2003,9 @@ export const TourDetailPage = ({
</div>
{/* Main Content Area - Điều chỉnh max-width khi ở chế độ bản đồ */}
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} mt-6 px-4 pb-24`}>
<div className={`${activeTab === 'plan' && viewMode === 'map' ? 'w-full' : 'max-w-2xl mx-auto'} px-4 pb-24`}>
{/* Tab Switcher */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-20 z-20">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-1 flex mb-6 sticky top-[60px] z-40">
{tabs.map((tab) => (
<button
key={tab.id}
@@ -2013,7 +2013,7 @@ export const TourDetailPage = ({
setActiveTab(tab.id as any);
if (tab.id === 'chat') setUnreadChatCount(0);
}}
className={`flex-1 flex items-center justify-center py-3 rounded-xl text-sm font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
className={`flex-1 flex items-center justify-center py-2 rounded-xl text-xs font-semibold transition-all relative ${tab.id === 'settings' && isPublicView ? 'hidden' : ''} ${ // Hide settings tab in public view
activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-50'
@@ -3004,8 +3004,17 @@ export const TourDetailPage = ({
/>
)}
{activeTab === 'chat' && currentTour && (
<div className="animate-in fade-in slide-in-from-bottom-2">
<TourChat tourId={currentTour.id} />
<div className="fixed left-0 right-0 bottom-0 flex flex-col bg-white z-[15]" style={{top: 'calc(60px + 5px)'}}>
{/* Chat Group Header Banner - Fixed below nav + tabs, sticky to title */}
<div className="flex-shrink-0 p-3 border-b border-gray-200 bg-white flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-500" />
<div>
<h3 className="text-sm font-bold text-gray-800">Trò chuyện trong nhóm</h3>
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đi thông tin, hình nh đnh vị giữa các thành viên</p>
</div>
</div>
{/* TourChat with proper scrollable area */}
<TourChat tourId={currentTour.id} embedded={true} />
</div>
)}
</div>