fix: lưu ghi chú ở địa điểm vào ghi chú

This commit is contained in:
2026-06-24 17:05:21 +07:00
parent b998833371
commit 9dc1faee6f
8 changed files with 491 additions and 260 deletions
@@ -48,7 +48,7 @@ export const ItineraryTimeline = ({
}: {
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
onEditLocation?: (location: any) => void,
onQuickNote?: (name: string) => void,
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
onNavigate?: (location: any) => void,
onSuccess?: () => void,
isPublicView?: boolean
@@ -470,12 +470,12 @@ export const ItineraryTimeline = ({
<button
onClick={(e) => {
e.stopPropagation();
onQuickNote(location.name);
onQuickNote({ legId: leg.id, location, leg });
}}
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"
className="p-1.5 text-yotripLight-steel hover:text-yotripLight-text dark:text-yotripDark-muted dark:hover:text-yotripDark-lime rounded-lg hover:bg-gray-100 dark:hover:bg-yotripDark-surface-muted transition-colors duration-200"
title="Ghi chú nhanh"
>
<FileText className="w-3 h-3" />
<FileText className="w-4 h-4" />
</button>
)}
<button
+40 -40
View File
@@ -355,22 +355,22 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
{!isCreating && (
<div className="flex items-center gap-4 mb-8">
<div className="relative flex-1">
<input
type="text"
placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
</div>
<button
onClick={() => setIsCreating(true)}
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
>
<Plus className="w-6 h-6" />
</button>
<input
type="text"
placeholder="Tìm kiếm nội dung ghi chú..."
className="w-full pl-10 pr-4 py-3 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-2xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all text-sm text-[var(--text-primary)] dark:text-[var(--text-primary)] placeholder-[var(--text-muted)]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-muted)]" />
</div>
<button
onClick={() => setIsCreating(true)}
className="p-3 bg-amber-500 text-white rounded-2xl shadow-lg shadow-amber-200 hover:bg-amber-600 transition-all active:scale-95"
>
<Plus className="w-6 h-6" />
</button>
</div>
)}
{isLoading ? (
@@ -387,7 +387,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
value={noteForm.title}
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
/>
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col">
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm overflow-hidden min-h-[500px] flex flex-col h-full">
<ReactQuill
ref={quillRef}
theme="snow"
@@ -395,7 +395,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
onChange={(content) => setNoteForm({ ...noteForm, content })}
modules={quillModules}
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-editor]:flex-1 [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
className="flex-1 flex flex-col [&_.ql-container]:flex-1 [&_.ql-container]:flex [&_.ql-container]:flex-col [&_.ql-container]:h-full [&_.ql-container]:min-h-0 [&_.ql-editor]:flex-1 [&_.ql-editor]:overflow-y-auto [&_.ql-editor]:text-base [&_.ql-toolbar]:flex [&_.ql-toolbar]:flex-wrap sm:[&_.ql-toolbar]:flex-nowrap [&_.ql-toolbar]:border-0 [&_.ql-toolbar]:border-b [&_.ql-toolbar]:border-[var(--border)] [&_.ql-editor_table]:border-collapse [&_.ql-editor_table]:w-full [&_.ql-editor_td]:border [&_.ql-editor_td]:border-[var(--border)] [&_.ql-editor_td]:p-2 [&_.ql-editor_.ql-align-center]:text-center [&_.ql-editor_.ql-align-right]:text-right [&_.ql-editor_.ql-align-justify]:text-justify [&_.ql-picker-options]:!z-[50] [&_.ql-picker-options]:!shadow-xl [&_.ql-picker-options]:!rounded-xl [&_.ql-picker-options]:!border-[var(--border)] [&_.ql-stroke]:!stroke-[var(--text-secondary)] [&_.ql-fill]:!fill-[var(--text-secondary)] [&_button:hover_.ql-stroke]:!stroke-amber-500 [&_button:hover_.ql-fill]:!stroke-amber-500 [&_button.ql-active_.ql-stroke]:!stroke-amber-500"
/>
</div>
<div className="flex gap-3">
@@ -416,26 +416,26 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
) : filteredNotes.length > 0 ? (
<div className="grid grid-cols-1 gap-4">
{filteredNotes.map((note) => (
<div key={note.id} className="bg-[var(--surface)] p-5 rounded-3xl border border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
<div key={note.id} className="bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl border border-[var(--border)] dark:border-[var(--border)] shadow-sm hover:shadow-md transition-all group">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-bold text-[var(--text-primary)]">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] font-bold uppercase mt-1">
<h4 className="font-bold text-[var(--text-primary)] dark:text-[var(--text-primary)]">{note.title}</h4>
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] dark:text-[var(--text-muted)] font-bold uppercase mt-1">
<Calendar className="w-3 h-3" />
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
{new Date(note.createdAt).toLocaleDateString("vi-VN")}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => handleEditNote(note)}
className="p-2 text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-blue-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Sửa ghi chú"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNote(note.id)}
className="p-2 text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
className="p-2 text-[var(--text-muted)] dark:text-[var(--text-muted)] hover:text-red-500 hover:bg-[var(--background)] dark:hover:bg-[var(--background)] rounded-xl transition-all opacity-0 group-hover:opacity-100"
title="Xóa ghi chú"
>
<Trash2 className="w-4 h-4" />
@@ -443,30 +443,30 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
</div>
</div>
<div
className="text-sm text-[var(--text-secondary)] line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_td]:border [&_td]:border-[var(--border)] [&_td]:p-2 [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify"
className="text-sm text-[var(--text-secondary)] dark:text-[var(--text-secondary)] line-clamp-3 prose prose-sm max-w-none ql-editor !p-0 [&_table]:border-collapse [&_table]:w-full [&_table]:text-[var(--text-primary)] dark:[&_table]:text-[var(--text-primary)] [&_td]:border [&_td]:border-[var(--border)] dark:[&_td]:border-[var(--border)] [&_td]:p-2 [&_td]:bg-[var(--surface)] dark:[&_td]:bg-[var(--background-alt)] [&_.ql-align-center]:text-center [&_.ql-align-right]:text-right [&_.ql-align-justify]:text-justify [&_table]:table-fixed [&_td]:break-words"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
</div>
))}
</div>
) : (
<div className="text-center py-24 bg-[var(--surface)] rounded-[40px] border-2 border-dashed border-[var(--border)] shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" />
<div className="text-center py-24 bg-[var(--surface)] dark:bg-[var(--background-alt)] rounded-[40px] border-2 border-dashed border-[var(--border)] dark:border-[var(--border)] shadow-inner">
<div className="w-20 h-20 bg-amber-50 rounded-3xl flex items-center justify-center mx-auto mb-6 text-amber-500">
<FileText className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 dark:text-slate-400 max-w-xs mx-auto mt-2">
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
</p>
{!searchQuery && (
<button
onClick={() => setIsCreating(true)}
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
>
<Plus className="w-5 h-5" /> Tạo ghi chú mới
</button>
)}
</div>
<h3 className="text-xl font-bold text-gray-900">{searchQuery ? 'Không tìm thấy ghi chú' : 'Chưa có ghi chú nào'}</h3>
<p className="text-sm text-gray-400 max-w-xs mx-auto mt-2">
{searchQuery ? 'Hãy thử tìm kiếm với từ khóa khác.' : 'Hãy lưu lại những cảm nhận, lịch trình riêng hoặc các lưu ý quan trọng cho hành trình của bạn.'}
</p>
{!searchQuery && (
<button
onClick={() => setIsCreating(true)}
className="mt-8 inline-flex items-center gap-2 bg-amber-500 hover:bg-amber-600 text-white px-8 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-lg shadow-amber-200 active:scale-95"
>
<Plus className="w-5 h-5" /> Tạo ghi chú mới
</button>
)}
</div>
)}
</div>
</div>
+76 -57
View File
@@ -969,6 +969,8 @@ export const TourDetailPage = ({
// State cho Modal ghi chú nhanh
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
const [quickNoteLegId, setQuickNoteLegId] = useState<string | null>(null);
const [quickNoteLocation, setQuickNoteLocation] = useState<any>(null);
const [quickNoteInput, setQuickNoteInput] = useState('');
// Đồng bộ hóa các ô nhập dữ liệu cài đặt với currentTour khi tour tải/cập nhật
@@ -1559,62 +1561,76 @@ export const TourDetailPage = ({
}
};
// Hàm ghi chú nhanh cho từng địa điểm, tự động append vào note chung của Tour
const handleQuickNote = (locationName: string) => {
if (isPublicView) return;
setQuickNoteLocName(locationName);
const reverseGeocode = async (lat: number, lng: number): Promise<string> => {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`, {
headers: { 'Accept': 'application/json' }
});
if (res.ok) {
const data = await res.json();
return data.display_name || '';
}
} catch (e) {
console.warn('Reverse geocode failed:', e);
}
return '';
};
const handleQuickNote = async (data: { legId: string; location: any; leg: any }) => {
if (isPublicView || !currentTour) return;
setQuickNoteLocName(data.location.name);
setQuickNoteLegId(data.legId);
setQuickNoteLocation(data.location);
setQuickNoteInput('');
};
// Hàm xử lý submit ghi chú nhanh từ modal
const submitQuickNote = () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
const submitQuickNote = async () => {
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour || !quickNoteLegId || !quickNoteLocation) return;
const content = quickNoteInput;
const storedUser = localStorage.getItem('user');
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
const userName = user.name || 'Thành viên';
const now = new Date().toLocaleString('vi-VN');
const noteTitle = `Ghi chú của hành trình: ${currentTour.title}`;
const savedNotes = localStorage.getItem('my_journey_notes');
let notes = [];
try {
notes = savedNotes ? JSON.parse(savedNotes) : [];
} catch (e) { notes = []; }
let targetNote = notes.find((n: any) => n.tourId === currentTour.id || n.title === noteTitle);
// Tạo khối nội dung dạng "Textbox" chuyên nghiệp
const newContentLine = `
<div class="quick-note-box" style="border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; background: #fffbeb; border-radius: 8px; border: 1px solid #fef3c7; box-shadow: 0 2px 4px rgba(0,0,0,0.02);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<span style="font-size: 10px; color: #b45309; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;">🕒 ${now} • 👤 ${userName}</span>
</div>
<p style="margin: 0; color: #4b5563; font-size: 14px; line-height: 1.5;"><strong>📍 ${quickNoteLocName}:</strong> ${content}</p>
</div>
<p></p>
`;
if (targetNote) {
targetNote.tourId = currentTour.id;
targetNote.title = noteTitle;
targetNote.content += newContentLine;
} else {
const newNote = {
id: Date.now().toString(),
tourId: currentTour.id,
title: noteTitle,
content: `<p>Bắt đầu lập kế hoạch cho chuyến đi <strong>${currentTour.title}</strong> của bạn tại đây...</p>` + newContentLine,
createdAt: new Date().toISOString()
};
notes.unshift(newNote);
let resolvedAddress = quickNoteLocation.address || '';
const hasCoords = quickNoteLocation.latitude && quickNoteLocation.longitude;
if ((!resolvedAddress || resolvedAddress.trim() === '') && hasCoords) {
const geo = await reverseGeocode(+quickNoteLocation.latitude, +quickNoteLocation.longitude);
resolvedAddress = geo || `${quickNoteLocation.latitude}, ${quickNoteLocation.longitude}`;
}
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
setQuickNoteLocName(null);
setQuickNoteInput('');
const formattedTime = quickNoteLocation.plannedStart
? new Date(quickNoteLocation.plannedStart).toLocaleString('vi-VN')
: "Thời gian tùy hứng";
const userInputText = content;
const noteSnippet = `<h4>📅 ${formattedTime} | 📍 ${resolvedAddress}</h4><ul><li><strong>Nhật ký:</strong> ${userInputText}</li></ul>`;
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/v1/tours/${currentTour.id}/notes/insert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
legId: quickNoteLegId,
noteSnippet
})
});
if (res.ok) {
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
} else {
const err = await res.json().catch(() => ({}));
notify({ title: 'Lỗi', message: err.message || 'Lỗi khi lưu ghi chú nhanh.', type: 'error' });
}
} catch (error) {
console.error('Lỗi khi lưu ghi chú nhanh:', error);
notify({ title: 'Lỗi', message: 'Lỗi mạng khi lưu ghi chú nhanh.', type: 'error' });
} finally {
setQuickNoteLocName(null);
setQuickNoteLegId(null);
setQuickNoteLocation(null);
setQuickNoteInput('');
}
};
// Hàm xử lý xóa Tour vĩnh viễn
@@ -2088,7 +2104,7 @@ export const TourDetailPage = ({
setIsAddLocationOpen(true);
}}
onQuickNote={(locName: string) => handleQuickNote(locName)}
onQuickNote={(data) => handleQuickNote(data)}
onSuccess={() => fetchTour(tourId)}
isPublicView={isPublicView}
onNavigate={handleNavigateToLocation}
@@ -2274,13 +2290,16 @@ export const TourDetailPage = ({
<div className="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
<div className="flex flex-col gap-1">
<button
onClick={() => handleQuickNote(loc.name)}
className="w-full flex items-center justify-center gap-1.5 py-1.5 bg-amber-50 hover:bg-amber-100 text-amber-600 rounded-lg text-[10px] font-black transition-all border border-amber-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
</button>
<button
onClick={() => {
const leg = legs.find((l: any) => l.locations.some((lo: any) => lo.id === loc.id));
handleQuickNote({ legId: loc.legId || leg?.id || '', location: loc, leg: leg || {} });
}}
className="flex items-center justify-center gap-1.5 py-1.5 text-yotripLight-steel hover:text-yotripLight-text dark:text-yotripDark-muted dark:hover:text-yotripDark-lime rounded-lg hover:bg-gray-100 dark:hover:bg-yotripDark-surface-muted transition-colors duration-200 border border-gray-100"
>
<FileText className="w-3 h-3" />
GHI CHÚ NHANH
</button>
<button
onClick={() => {
setCommentLocationId(loc.id);