fix: lưu ghi chú ở địa điểm vào ghi chú
This commit is contained in:
@@ -1,142 +0,0 @@
|
|||||||
# To AI Agent: Refactor Itinerary Timeline into an Exclusive File Tree Directory System (Single Expansion Mode)
|
|
||||||
|
|
||||||
## 1. Context & Architectural Analogy
|
|
||||||
We are refactoring the `ItineraryTimeline` component on the mobile view (`yotrip.labz.io.vn`) using a **File Tree Directory System** analogy:
|
|
||||||
- **Parent Folders ("Thư mục mẹ"):** Represented by the Stages ("Chặng 1", "Chặng 2"...).
|
|
||||||
- **Child Nodes ("Thư mục con"):** Represented by the Locations/Destinations inside that stage (`Trần Cao Vân`, `Đồng Khởi`...).
|
|
||||||
|
|
||||||
### CRITICAL LOGIC CONSTRAINT (Exclusive Accordion):
|
|
||||||
- When a Parent Folder (Stage) is **collapsed**, all of its Child Nodes (Locations) must immediately hide cleanly inside it.
|
|
||||||
- **Single Expansion Rule:** The system must enforce an **exclusive single-expansion mode**. On the entire timeline screen, **MAXIMUM ONE** Parent Folder can be expanded at any given time.
|
|
||||||
- Opening/Expanding a new Stage folder must **automatically collapse** whichever Stage folder was previously open.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. State Management Specification (For Script/Logic Implementation)
|
|
||||||
|
|
||||||
To enforce the "Maximum 1 Expanded Folder" rule, do NOT use isolated individual boolean flags for each stage. Instead, implement a centralized single-active-state variable:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Example React State Hook Blueprint:
|
|
||||||
// Track the ID or index of the single active expanded stage folder.
|
|
||||||
// If null, all stages are collapsed.
|
|
||||||
const [expandedStageId, setExpandedStageId] = useState(initialStageId);
|
|
||||||
|
|
||||||
const handleStageToggle = (stageId) => {
|
|
||||||
// If clicking the already open folder, close it. Otherwise, open the new one and shut the rest.
|
|
||||||
setExpandedStageId(prevId => prevId === stageId ? null : stageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
## 3. Component DOM Tree & CSS Blueprint
|
|
||||||
### A. Component Layout Structure
|
|
||||||
|
|
||||||
<div class="file-tree-itinerary-container">
|
|
||||||
|
|
||||||
<div class="fixed-top-header-block">
|
|
||||||
<header class="main-tour-header">...</header>
|
|
||||||
<nav class="sub-navigation-tabs">...</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="directory-scroll-viewport">
|
|
||||||
|
|
||||||
<section class="folder-node-wrapper collapsed">
|
|
||||||
<div class="folder-header-row" onclick="handleStageToggle('stage_1')">
|
|
||||||
<span class="folder-badge-index">1</span>
|
|
||||||
<h3 class="folder-title">Chặng khởi đầu</h3>
|
|
||||||
</div>
|
|
||||||
<div class="folder-child-content-box">
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="folder-node-wrapper expanded">
|
|
||||||
<div class="folder-header-row" onclick="handleStageToggle('stage_2')">
|
|
||||||
<span class="folder-badge-index">2</span>
|
|
||||||
<h3 class="folder-title">Chặng 2</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="folder-child-content-box">
|
|
||||||
<div class="child-nodes-list">
|
|
||||||
<div class="location-child-card">Trần Cao Vân</div>
|
|
||||||
<div class="location-child-card">Đồng Khởi</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### B. Core CSS Layout Styles
|
|
||||||
|
|
||||||
.file-tree-itinerary-container {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
height: 100dvh !important;
|
|
||||||
width: 100vw !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.directory-scroll-viewport {
|
|
||||||
flex: 1 1 0% !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
background-color: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base style for folder blocks stacked flush against each other */
|
|
||||||
.folder-node-wrapper {
|
|
||||||
width: 100% !important;
|
|
||||||
background-color: #ffffff;
|
|
||||||
margin-bottom: 1px !important; /* Micro hair-line divider between folders */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-header-row {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px !important;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #ffffff;
|
|
||||||
cursor: pointer;
|
|
||||||
/* Sticky behavior remains active when scrolling within an open directory */
|
|
||||||
position: sticky !important;
|
|
||||||
top: 0px;
|
|
||||||
z-index: 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS Grid Transition Engine for smooth directory expansions */
|
|
||||||
.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%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.child-nodes-list {
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- MUTUALLY EXCLUSIVE STATE STYLES --- */
|
|
||||||
|
|
||||||
/* Collapsed Folder: Sub-files shrink immediately to 0 height */
|
|
||||||
.folder-node-wrapper.collapsed .folder-child-content-box {
|
|
||||||
grid-template-rows: 0fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded Folder: Opens dynamically to accommodate dynamic content height */
|
|
||||||
.folder-node-wrapper.expanded .folder-child-content-box {
|
|
||||||
grid-template-rows: 1fr !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
## 4. Acceptance Criteria for AI Agent Verification
|
|
||||||
[ ] Mutually Exclusive Test: Clicking an inactive Stage header while another stage is open must trigger a simultaneous transition: the old stage collapses back into a tight header row, and the clicked stage expands its location cards.
|
|
||||||
|
|
||||||
[ ] Zero-Gap Validation: All collapsed folder components must sit completely flush against the top tab block and one another, eliminating all gray margin bleeding.
|
|
||||||
|
|
||||||
[ ] Under-Clip Containment: When scrolling a long open folder, the list items must slide behind their sticky active parent folder row and disappear without visual overlapping artifacts.
|
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
Markdown
|
||||||
|
# To AI Agent: Implement Automatic Tour Note Generation & Dynamic Timeline Quick Note Insertion
|
||||||
|
|
||||||
|
## 1. Context & Feature Overview
|
||||||
|
We are implementing an interconnected Note System for the Yotrip Travel Planner application. The feature spans across three core components: `TourDetailPage.tsx`, `MyNotePage.tsx`, and `ItineraryTimeline.tsx`.
|
||||||
|
|
||||||
|
### Core Requirements:
|
||||||
|
1. **Auto-Note Initialization:** When a new Tour is created in `TourDetailPage.tsx`, the system must automatically instantiate a matching master note record inside the `MyNotePage.tsx` system linked by `tourId`.
|
||||||
|
2. **Strict Document Schema Hierarchy:** The note content must format itself using structured headings reflecting the tour's legs (stages) and location points.
|
||||||
|
3. **Timeline Quick Note Insertion:** Inside `ItineraryTimeline.tsx`, each location node must feature a "Quick Note Button". Clicking it must immediately stringify location metadata (Timestamp + Reverse-Geocoded Address + User Text) and `INSERT` it dynamically under the correct Stage section inside the master note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Note Structure & Markdown Payload Template
|
||||||
|
|
||||||
|
When initializing a tour or syncing content, the document data payload within the database/state framework must stringify exactly to this structural template:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [Tên Tour]
|
||||||
|
|
||||||
|
## Ghi chú chung
|
||||||
|
*(Nội dung ghi chú tổng quan của chuyến đi...)*
|
||||||
|
|
||||||
|
## Ghi chú: [Tên Chặng 1]
|
||||||
|
#### 📅 [Ngày giờ] | 📍 [Tên địa điểm - Phân giải từ tọa độ]
|
||||||
|
- **Nhật ký:** [Nội dung người dùng nhập vào ở Điểm này trên Timeline]
|
||||||
|
|
||||||
|
## Ghi chú: [Tên Chặng 2]
|
||||||
|
#### ...
|
||||||
|
|
||||||
|
## 3. Detailed Logic & Component Specifications
|
||||||
|
### Step 1: TourDetailPage.tsx - Creation Hook Handler
|
||||||
|
When the user submits the "Create Tour" form successfully, inject an asynchronous action handler to create the accompanying node container:
|
||||||
|
|
||||||
|
TypeScript
|
||||||
|
// Blueprint for Tour Creation Action Hook
|
||||||
|
const handleCreateTour = async (tourData: any) => {
|
||||||
|
const newTour = await api.tours.create(tourData);
|
||||||
|
|
||||||
|
if (newTour) {
|
||||||
|
// Generate the initial markdown note layout template
|
||||||
|
const initialNoteBody = `# ${newTour.title}\n\n## Ghi chú chung\n\n`;
|
||||||
|
|
||||||
|
await api.notes.create({
|
||||||
|
tourId: newTour.id,
|
||||||
|
title: `Ghi chú: ${newTour.title}`,
|
||||||
|
content: initialNoteBody,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
### Step 2: ItineraryTimeline.tsx - Quick Note Component Injection
|
||||||
|
Locate the location list renderer (leg.locations.map) adjacent to the white circular checkpoint nodes. Insert a small "Quick Note" button (utilizing Lucide icon FileText or similar).
|
||||||
|
|
||||||
|
JSX Target Insertion Blueprint
|
||||||
|
JavaScript
|
||||||
|
|
||||||
|
{/* Quick Note Button Interface sitting right near the action buttons of the card */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleQuickNoteInsert(leg.id, location.id, location)}
|
||||||
|
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"
|
||||||
|
title="Ghi chú nhanh điểm này"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
### Step 3: Coordinate Resolution & String Insertion Engine
|
||||||
|
Implement the core method to reverse-geocode latitudes/longitudes, construct the standard string layout, and push the patch to the existing text repository of the note.
|
||||||
|
|
||||||
|
TypeScript
|
||||||
|
|
||||||
|
const handleQuickNoteInsert = async (legId: string, locationId: string, locationData: any) => {
|
||||||
|
// 1. Resolve coordinates to a readable address string
|
||||||
|
const resolvedAddress = await reverseGeocode(locationData.lat, locationData.lng) || locationData.addressName;
|
||||||
|
|
||||||
|
// 2. Format localized Date/Time
|
||||||
|
const formattedTime = locationData.plannedStart
|
||||||
|
? new Date(locationData.plannedStart).toLocaleString('vi-VN')
|
||||||
|
: "Thời gian tùy hứng";
|
||||||
|
|
||||||
|
// 3. Extract user text inputted on the timeline card
|
||||||
|
const userInputText = locationData.timelineComment || "Không có ghi chú thêm.";
|
||||||
|
|
||||||
|
// 4. Synthesize the injection block
|
||||||
|
const noteSnippet = `#### 📅 ${formattedTime} | 📍 ${resolvedAddress}\n- **Nhật ký:** ${userInputText}\n\n`;
|
||||||
|
|
||||||
|
// 5. Trigger API/State update to find the master note by tourId,
|
||||||
|
// locate or append the '## Ghi chú: [Tên Chặng]' marker, and inject the snippet directly below it.
|
||||||
|
await api.notes.insertSectionByTourId(tourId, legId, noteSnippet);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 4. UI Style Integration Constraints
|
||||||
|
Apply the application design system colors directly to the new buttons.
|
||||||
|
|
||||||
|
In Light mode, buttons must remain a clean #28536b (Yale Blue) or soft #7ea8be (Steel Blue).
|
||||||
|
|
||||||
|
In Dark mode, hover interactions must transition to #bce784 (Lime Cream) with a smooth transition-colors duration-200 modifier.
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria for Verification
|
||||||
|
[ ] Instantiation Check: Create a test Tour titled "Hoa vàng cỏ xanh 2026". Navigate to MyNotePage and confirm a note document with the exact title has been instantly spawned.
|
||||||
|
|
||||||
|
[ ] Geocoding & String Format Check: Click the Quick Note button on a location card with coordinates (e.g., 15.56, 108.49). The string appended to the note must fully resolve the address (e.g., "Tam Kỳ, Quảng Nam") instead of dumping raw coordinate numbers.
|
||||||
|
|
||||||
|
[ ] Structural Targeting: Ensure that inserting a quick note from Chặng 2 appends the text directly beneath the ## Ghi chú: Chặng 2 markdown anchor block, without scrambling or overwriting the text blocks of Chặng 1.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# To AI Agent: Fix Overlapping Text and Layout Overflow in MyNotePage Note Cards
|
||||||
|
|
||||||
|
## 1. Context & Layout Bug Analysis
|
||||||
|
We are fixing a severe rendering and layout bug inside the Note Card component of `MyNotePage.tsx` as captured in `image_8a449e.png`:
|
||||||
|
|
||||||
|
- **Bug 1 (Chữ đè chồng lên nhau):** The text lines inside the note body (headings, dates, journal contents) are collapsing vertically and rendering directly on top of each other. This is caused by rogue `absolute` positioning or a broken flex/grid system on inner content elements.
|
||||||
|
- **Bug 2 (Tràn ra khỏi Board chứa):** Long text strings (such as resolved address lines with "Đà Nẵng, Vietnam" or "Tuy Hòa, Phú Yên") are breaking out horizontally and vertically beyond the rounded border bounds of the card.
|
||||||
|
- **Goal:** Clean up the typography layout engine so that elements stack naturally in a vertical flow (`block` or `flex-col`), wrap text cleanly when boundaries are met, and respect the container's height/scroll rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Refactoring Instructions
|
||||||
|
|
||||||
|
### Step 1: Clean Up the Note Content Wrapper Class
|
||||||
|
Locate the container rendering the inner markdown text inside the note board card. Force it to follow a regular vertical block layout flow and ensure text wrapping is active.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{/* ❌ OLD COLLAPSED CONTAINER (Dự đoán đang bị sai class) */}
|
||||||
|
<div className="absolute ..."> or <div className="h-full ...">
|
||||||
|
|
||||||
|
{/* ✅ NEW FIXED CONTAINER STRUCTURE */}
|
||||||
|
<div className="flex flex-col gap-3 w-full text-left overflow-y-auto max-h-[250px] pr-2 scrollbar-thin">
|
||||||
|
{/* Ensure markdown rendering outputs elements as block-level */}
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-pre-wrap text-yotripDark-text-primary">
|
||||||
|
{/* Inside here, headings (##, ####) and lists (-) must stack naturally */}
|
||||||
|
{note.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Step 2: Fix Inner Line Items Styles (If parsing strings manually)
|
||||||
|
If you are split-parsing the lines manually (e.g., parsing 📅 and 📍 into individual rows), ensure NO element inside uses absolute. Every line item must be relative or static.
|
||||||
|
|
||||||
|
/* Inject these explicit fixes into index.css under @layer components if needed */
|
||||||
|
.note-content-line {
|
||||||
|
position: relative !important; /* Force breakout from any absolute parent trap */
|
||||||
|
display: block !important; /* Clear any inline overlap */
|
||||||
|
width: 100% !important;
|
||||||
|
word-break: break-word !important; /* Force text to wrap instead of bleeding out */
|
||||||
|
white-space: normal !important; /* Overwrite any nowrap rule */
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
## 3. Full Component JSX Layout Optimization Blueprint
|
||||||
|
Update the single Note Card render template inside MyNotePage.tsx to match this stable layout standard:
|
||||||
|
|
||||||
|
export const NoteCard = ({ note }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full bg-yotripDark-surface border border-yotripDark-border rounded-2xl p-5 shadow-md hover:shadow-lg transition-all flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* 1. Header Zone (Title & Date) */}
|
||||||
|
<div className="flex flex-col gap-1 border-b border-yotripDark-border pb-3">
|
||||||
|
<h3 className="text-lg font-bold text-white break-words pr-8">
|
||||||
|
{note.title}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-yotripDark-muted flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
{note.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Content Zone - FIXES THE BUG IN IMAGE */}
|
||||||
|
<div className="w-full flex flex-col gap-3 overflow-y-auto max-h-[280px] text-sm text-slate-200 pr-1">
|
||||||
|
{/* Dynamic content rendering with forced layout safety rails */}
|
||||||
|
<div className="space-y-3 break-words whitespace-pre-wrap text-left select-text">
|
||||||
|
{/* Ensure raw strings or processed lines flow down smoothly */}
|
||||||
|
{note.content.split('\n').map((line, index) => {
|
||||||
|
if (line.trim() === '') return null;
|
||||||
|
return (
|
||||||
|
<p key={index} className="leading-relaxed text-slate-200 block w-full m-0 p-0">
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
## 4. Verification Checklist for AI Agent
|
||||||
|
[ ] No Overlap: Verify that the "## Ghi chú chung...", "Ghi chú: Xuất phát", and "📅 Thời gian tùy hứng" text elements sit on unique vertical lines. They must never occupy the same space.
|
||||||
|
|
||||||
|
[ ] Horizontal Bound Test: Long addresses (e.g., Apec mandala Victor condotel, Hùng Vương, Phường Tuy Hòa...) must trigger a soft wrap onto line 2 and line 3 instead of punching through the card's right border.
|
||||||
|
|
||||||
|
[ ] Scroll Containment: If the text length exceeds the card's designated size, it must elegantly clip inside and provide a clean vertical scrollbar, rather than dripping out underneath the board container.
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
# NOTE_EDIT.md
|
||||||
|
|
||||||
|
## MyNotePage.tsx
|
||||||
|
**Đường dẫn:** `frontend/src/pages/MyNotePage.tsx`
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
Trang quản lý ghi chú cá nhân (My Notes) của người dùng trong một tour. Hỗ trợ tạo, sửa, xóa, tìm kiếm ghi chú và đồng bộ dữ liệu giữa client và server.
|
||||||
|
|
||||||
|
### Cấu trúc & Luồng hoạt động chính
|
||||||
|
- **State quản lý:**
|
||||||
|
- `notes`: danh sách ghi chú hiện tại
|
||||||
|
- `isCreating`: điều khiển hiển thị form tạo/sửa ghi chú
|
||||||
|
- `editingNoteId`: xác định đang sửa ghi chú nào
|
||||||
|
- `noteForm`: lưu trữ form data `{ title, content }`
|
||||||
|
- `searchQuery`: từ khóa tìm kiếm
|
||||||
|
- **Render có 3 nhánh:**
|
||||||
|
1. Không tạo ghi chú + không có ghi chú: hiển thị màn hình trống + nút "Tạo ghi chú mới"
|
||||||
|
2. `isCreating = true`: hiển thị form nhập tiêu đề + ReactQuill + nút Lưu/Hủy
|
||||||
|
3. `filteredNotes.length > 0`: hiển thị danh sách ghi chú dạng grid card
|
||||||
|
- **Lưu ghi chú (handleSaveNote):**
|
||||||
|
- Nếu đang `edit`: cập nhật note trong `notes` gọi API PUT
|
||||||
|
- Nếu `create` mới: tạo `temp_` id, thêm vào đầu mảng `notes`, gọi API POST để lấy id thật
|
||||||
|
- Sau khi API thành công: cập nhật `id` thật và đánh dấu `synced: true`
|
||||||
|
- **Xóa ghi chú:** hỏi confirm → đánh dấu `deletedLocally: true` → gọi API DELETE → xóa khỏi UI
|
||||||
|
- **Đồng bộ offline (syncLocalNotesToServer):**
|
||||||
|
- Xử lý tuần tự 3 nhóm: `deletedLocally` → `temp_` (chưa đồng bộ) → `edited` (synced: false)
|
||||||
|
- Mỗi nhóm có retry riêng, lỗi chỉ log console
|
||||||
|
- **Fetch & Cache (fetchServerNotes + useEffect):**
|
||||||
|
- Khi load trang: đọc `localStorage` key `my_journey_notes_{tourId}` làm cache ban đầu
|
||||||
|
- Gọi API GET `/api/v1/tours/${tourId}/notes` để lấy dữ liệu server
|
||||||
|
- Merge: giữ `unsyncedNotes` (temp_, synced:false, deletedLocally) + lọc khỏi server
|
||||||
|
- Sort theo `createdAt` giảm dần (mới nhất lên đầu)
|
||||||
|
- Lưu lại `localStorage` sau mỗi thay đổi
|
||||||
|
- **Tìm kiếm (filteredNotes):**
|
||||||
|
- Lọc theo title HOẶC content (strip HTML tag)
|
||||||
|
- Không lọc note đã `deletedLocally`
|
||||||
|
|
||||||
|
### ReactQuill configuration
|
||||||
|
- Module: table, header(1,2), bold/italic/underline/strike, align, indent, bullet/check list, link/image/table/clean
|
||||||
|
- Custom Icons: gán SVG từ `lucide-react` vào `Quill.import('ui/icons')`
|
||||||
|
- Table handler: dùng `prompt` nhập số hàng/cột → gọi `quill.getModule('table').insertTable()`
|
||||||
|
|
||||||
|
### Theme/Class cần lưu ý
|
||||||
|
- Wrapper ReactQuill: `bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm min-h-[500px] flex flex-col`
|
||||||
|
- CSS class ở ReactQuill đã fix: `.ql-container` lấy `h-full, min-h-0`; `.ql-editor` lấy `flex-1, overflow-y-auto` để nội dung dài cuộn trong khung
|
||||||
|
- Note card: `bg-[var(--surface)] dark:bg-[var(--surface-muted)] p-5 rounded-3xl`
|
||||||
|
- Màu chính: `text-amber-500` cho buttons, header
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TourDetailPage.tsx
|
||||||
|
**Đường dẫn:** `frontend/src/pages/TourDetailPage.tsx`
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
Trang chi tiết tour, tổng hợp toàn bộ thông tin: bản đồ interactive, lộ trình, gallery ảnh, manage members, quản lý chi phí, chat, và chức năng share. Trang này rất nặng (~3400 dòng) nên cần chú ý các captured props và callback pattern.
|
||||||
|
|
||||||
|
### Cấu trúc chính
|
||||||
|
- **Header:** sticky top bar có các tab: Timeline | Spot / Location | Expenses | Chat
|
||||||
|
- **Tabs switch:** điều khiển hiển thị `ItineraryTimeline`, `ExpenseManager`, `TourChat`
|
||||||
|
- **Map section:** leaflet map kết hợp MarkerCluster, Polyline vẽ lộ trình OSRM, Recenter button
|
||||||
|
|
||||||
|
### Key logic/Data flow
|
||||||
|
- `useTourStore` là state chính: lưu `currentTour`, `legs`, `locations`, `userRole`, `mapCenter`
|
||||||
|
- **OSRM Routes:** kết quả từ backend đa segment, `combineSegmentRoutes()` gộp thành 1 polyline
|
||||||
|
- **Sync data:** `addLocation`, `editLocation`, `deleteLocation` gọi API và cập nhật store
|
||||||
|
- **PDF Export:** dùng `jsPDF` + `jspdf-autotable` xuất hóa đơn/nhật ký tour
|
||||||
|
- **ShareJourney button:** tạo public link hoặc mở share modal
|
||||||
|
- **Socket.IO:** `io()` kết nối realtime cho chat và comment
|
||||||
|
- **Offline-first:** nhiều actions gọi API nhưng không block UI; có try/catch và fallback
|
||||||
|
|
||||||
|
### Các modal/components con được gọi
|
||||||
|
- `AddLocationModal`, `MembersTab`, `ExpenseManager`, `CommentModal`, `AddPhotoModal`, `TourChat`
|
||||||
|
- `MapContextMenu`: menu right-click trên map để bắt đầu/kết thúc/add to leg
|
||||||
|
- `MapRotationHandler`: xoay map theo hướng di chuyển (cumulative rotation để tránh nhảy -360→0)
|
||||||
|
- `MapHoverTip`: tooltip xuất hiện sau 3s khi hover trên map (cho user biết tip right-click)
|
||||||
|
|
||||||
|
### Theme & Styling cần lưu ý
|
||||||
|
- Map marker icon: dùng `unpkg.com/leaflet@1.9.4` (fixed URL) do Vite bundler không nhận asset mặc định
|
||||||
|
- Màu primary tour: `text-blue-600`, accent: `text-amber-500`
|
||||||
|
- Sticky header: `bg-[var(--surface)]/80 backdrop-blur-md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ItineraryTimeline.tsx
|
||||||
|
**Đường dẫn:** `frontend/src/components/ItineraryTimeline.tsx`
|
||||||
|
|
||||||
|
### Mục đích
|
||||||
|
Component hiển thị lộ trình đa chặng (multi-leg itinerary) dạng timeline accordion. Mỗi `leg` là 1 section có thể expand/collapse độc lập (exclusive mode).
|
||||||
|
|
||||||
|
### Cấu trúc chính
|
||||||
|
- **props:**
|
||||||
|
- `onAddLocation(legId, isStart?, isEnd?)`: callback mở modal thêm địa điểm
|
||||||
|
- `onEditLocation(location)`: callback mở modal sửa địa điểm
|
||||||
|
- `onQuickNote(name)`: callback mở modal ghi chú nhanh
|
||||||
|
- `onNavigate(location)`: callback điều hướng trên map
|
||||||
|
- `onSuccess()`: callback sau khi xóa/edit thành công
|
||||||
|
- `isPublicView`: boolean, hide edit/delete buttons khi true
|
||||||
|
- **State:**
|
||||||
|
- `expandedStageId`: id của leg đang mở (chỉ 1 mở tại 1 thời điểm) → mặc định leg đầu tiên
|
||||||
|
- `isEditModalOpen`, `editingLegData`: modal sửa chặng
|
||||||
|
- `isCommentModalOpen`, `commentLocationId`, `commentLocationName`: modal comment
|
||||||
|
|
||||||
|
### Logic đặc biệt
|
||||||
|
- **Exclusive expansion:** `toggleStageExpanded` đảm bảo chỉ 1 leg mở tại 1 thời điểm (click lại để đóng)
|
||||||
|
- **Distance & Travel time:**
|
||||||
|
- Tính haversine giữa 2 điểm liên tiếp (`allLocations` flat)
|
||||||
|
- Convert giữa các chặng: lấy `prevLegLastLoc` → tính khoảng cách → hiển thị "Tiếp nối từ ..."
|
||||||
|
- `leg.totalDistance` + `formatTravelTime()` để đổi sang "X giờ Y phút"
|
||||||
|
- **Dwell time:** thời gian dừng tại mỗi điểm = `plannedEnd - plannedStart`
|
||||||
|
- **Marker milestones:**
|
||||||
|
- Start point: `plannedStart` timestamp = 0
|
||||||
|
- End point: `plannedEnd` timestamp = 0
|
||||||
|
- `TimeVariance`: so sánh actual vs planned, hiển thị "Trễ X phút" / "Đúng giờ" / "Sớm X phút"
|
||||||
|
- **Accordion CSS:** dùng `grid-template-rows: 0fr → 1fr` transition (`folder-child-content-box`) để animate mở/đóng mượt
|
||||||
|
- **Comment count:** `handleCommentIncrement/Decrement` trực tiếp mutate `useTourStore` để cập nhật UI gần như tức thì mà không cần re-fetch API
|
||||||
|
|
||||||
|
### Giao tiếp với cha (TourDetailPage)
|
||||||
|
- `onAddLocation?.(leg.id, isFirst, isLast)` ↔ mở `AddLocationModal`
|
||||||
|
- `onEditLocation(loc)` ↔ pre-fill modal edit
|
||||||
|
- `onNavigate(loc)` ↔ mở notification/guide "Đang điều hướng" rồi gọi map navigate
|
||||||
|
- `onSuccess?.()` gọi sau delete/edit thành công để refresh từ cha
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lưu ý chung
|
||||||
|
- Cả 3 file đều dùng Tailwind CSS + theme variables từ `index.css`
|
||||||
|
- Offline/sync-first: ghi chú lưu localStorage trước, API sau
|
||||||
|
- Khi gọi API từ các hook, header gắn `Authorization: Bearer ${localStorage.getItem('token')}`
|
||||||
|
- `react-quill-new` dùng thay cho `react-quill` do tương thích React 18+
|
||||||
+51
-17
@@ -789,29 +789,14 @@ class TourController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-create default note template for new tour
|
const noteContent = `<h1>${filteredTitle}</h1><h2>Ghi chú chung</h2><p><em>Nội dung ghi chú tổng quan của chuyến đi...</em></p>`;
|
||||||
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
|
|
||||||
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
|
|
||||||
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
|
|
||||||
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
|
|
||||||
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
|
|
||||||
<h3>Key Items to Plan:</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Accommodations</li>
|
|
||||||
<li>Transportation</li>
|
|
||||||
<li>Activities & Attractions</li>
|
|
||||||
<li>Budget & Expenses</li>
|
|
||||||
<li>Important Contact Numbers</li>
|
|
||||||
<li>Special Requirements & Notes</li>
|
|
||||||
</ul>
|
|
||||||
<p><em>Add your planning notes here...</em></p>`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prisma.tourNote.create({
|
await this.prisma.tourNote.create({
|
||||||
data: {
|
data: {
|
||||||
tourId: tour.id,
|
tourId: tour.id,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
title: `[${filteredTitle}] - Initial Planning`,
|
title: `Ghi chú: ${filteredTitle}`,
|
||||||
content: noteContent
|
content: noteContent
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4110,6 +4095,55 @@ class TourNoteController {
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE)
|
||||||
|
@Post('insert')
|
||||||
|
async insertSection(
|
||||||
|
@Param('tourId') tourId: string,
|
||||||
|
@Body() body: { legId: string; noteSnippet: string },
|
||||||
|
@Req() req: any
|
||||||
|
) {
|
||||||
|
const tour = await this.prisma.tour.findUnique({ where: { id: tourId } });
|
||||||
|
if (!tour) throw new NotFoundException('Không tìm thấy tour');
|
||||||
|
|
||||||
|
const masterNote = await this.prisma.tourNote.findFirst({
|
||||||
|
where: { tourId, title: `Ghi chú: ${tour.title}`, isDeleted: false, userId: req.user.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
let note = masterNote;
|
||||||
|
if (!note) {
|
||||||
|
note = await this.prisma.tourNote.create({
|
||||||
|
data: {
|
||||||
|
tourId,
|
||||||
|
userId: req.user.id,
|
||||||
|
title: `Ghi chú: ${tour.title}`,
|
||||||
|
content: `# ${tour.title}\n\n## Ghi chú chung\n\n*(Nội dung ghi chú tổng quan của chuyến đi...)*\n\n`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const leg = await this.prisma.leg.findUnique({ where: { id: body.legId } });
|
||||||
|
const stageHeader = leg ? `<h2>Ghi chú: ${leg.note || `Chặng ${leg.sequence}`}</h2>` : '<h2>Ghi chú:</h2>';
|
||||||
|
|
||||||
|
let content = note.content;
|
||||||
|
const stageIndex = content.indexOf(stageHeader);
|
||||||
|
|
||||||
|
if (stageIndex === -1) {
|
||||||
|
content += `<br><br>${stageHeader}${body.noteSnippet}`;
|
||||||
|
} else {
|
||||||
|
const nextHeaderIndex = content.indexOf('<h2>', stageIndex + stageHeader.length);
|
||||||
|
if (nextHeaderIndex !== -1) {
|
||||||
|
content = content.slice(0, nextHeaderIndex) + body.noteSnippet + content.slice(nextHeaderIndex);
|
||||||
|
} else {
|
||||||
|
content += body.noteSnippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.tourNote.update({
|
||||||
|
where: { id: note.id },
|
||||||
|
data: { content }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('admin/notes')
|
@Controller('admin/notes')
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const ItineraryTimeline = ({
|
|||||||
}: {
|
}: {
|
||||||
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
onAddLocation?: (legId: string, isStart?: boolean, isEnd?: boolean) => void,
|
||||||
onEditLocation?: (location: any) => void,
|
onEditLocation?: (location: any) => void,
|
||||||
onQuickNote?: (name: string) => void,
|
onQuickNote?: (data: { legId: string; location: any; leg: any }) => void,
|
||||||
onNavigate?: (location: any) => void,
|
onNavigate?: (location: any) => void,
|
||||||
onSuccess?: () => void,
|
onSuccess?: () => void,
|
||||||
isPublicView?: boolean
|
isPublicView?: boolean
|
||||||
@@ -470,12 +470,12 @@ export const ItineraryTimeline = ({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
title="Ghi chú nhanh"
|
||||||
>
|
>
|
||||||
<FileText className="w-3 h-3" />
|
<FileText className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -355,22 +355,22 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
|||||||
{!isCreating && (
|
{!isCreating && (
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center gap-4 mb-8">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Tìm kiếm nội dung ghi chú..."
|
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"
|
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}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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)]" />
|
<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>
|
</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 ? (
|
{isLoading ? (
|
||||||
@@ -387,7 +387,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
|||||||
value={noteForm.title}
|
value={noteForm.title}
|
||||||
onChange={(e) => setNoteForm({ ...noteForm, title: e.target.value })}
|
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
|
<ReactQuill
|
||||||
ref={quillRef}
|
ref={quillRef}
|
||||||
theme="snow"
|
theme="snow"
|
||||||
@@ -395,7 +395,7 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
|||||||
onChange={(content) => setNoteForm({ ...noteForm, content })}
|
onChange={(content) => setNoteForm({ ...noteForm, content })}
|
||||||
modules={quillModules}
|
modules={quillModules}
|
||||||
placeholder="Bắt đầu viết cảm nhận của bạn tại đây..."
|
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>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -416,26 +416,26 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
|||||||
) : filteredNotes.length > 0 ? (
|
) : filteredNotes.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{filteredNotes.map((note) => (
|
{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 className="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-[var(--text-primary)]">{note.title}</h4>
|
<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)] font-bold uppercase mt-1">
|
<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" />
|
<Calendar className="w-3 h-3" />
|
||||||
{new Date(note.createdAt).toLocaleDateString('vi-VN')}
|
{new Date(note.createdAt).toLocaleDateString("vi-VN")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditNote(note)}
|
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ú"
|
title="Sửa ghi chú"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteNote(note.id)}
|
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ú"
|
title="Xóa ghi chú"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -443,30 +443,30 @@ export const MyNotePage = ({ tourId, onBack }: { tourId: string; onBack: () => v
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 }}
|
dangerouslySetInnerHTML={{ __html: note.content }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-24 bg-[var(--surface)] rounded-[40px] border-2 border-dashed border-[var(--border)] shadow-inner">
|
<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">
|
<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" />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -969,6 +969,8 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
// State cho Modal ghi chú nhanh
|
// State cho Modal ghi chú nhanh
|
||||||
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
|
const [quickNoteLocName, setQuickNoteLocName] = useState<string | null>(null);
|
||||||
|
const [quickNoteLegId, setQuickNoteLegId] = useState<string | null>(null);
|
||||||
|
const [quickNoteLocation, setQuickNoteLocation] = useState<any>(null);
|
||||||
const [quickNoteInput, setQuickNoteInput] = useState('');
|
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
|
// Đồ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 reverseGeocode = async (lat: number, lng: number): Promise<string> => {
|
||||||
const handleQuickNote = (locationName: string) => {
|
try {
|
||||||
if (isPublicView) return;
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`, {
|
||||||
setQuickNoteLocName(locationName);
|
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('');
|
setQuickNoteInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hàm xử lý submit ghi chú nhanh từ modal
|
const submitQuickNote = async () => {
|
||||||
const submitQuickNote = () => {
|
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour || !quickNoteLegId || !quickNoteLocation) return;
|
||||||
if (!quickNoteLocName || !quickNoteInput.trim() || !currentTour) return;
|
|
||||||
const content = quickNoteInput;
|
const content = quickNoteInput;
|
||||||
|
|
||||||
const storedUser = localStorage.getItem('user');
|
let resolvedAddress = quickNoteLocation.address || '';
|
||||||
const user = storedUser ? JSON.parse(storedUser) : { name: 'Thành viên' };
|
const hasCoords = quickNoteLocation.latitude && quickNoteLocation.longitude;
|
||||||
const userName = user.name || 'Thành viên';
|
if ((!resolvedAddress || resolvedAddress.trim() === '') && hasCoords) {
|
||||||
const now = new Date().toLocaleString('vi-VN');
|
const geo = await reverseGeocode(+quickNoteLocation.latitude, +quickNoteLocation.longitude);
|
||||||
|
resolvedAddress = geo || `${quickNoteLocation.latitude}, ${quickNoteLocation.longitude}`;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('my_journey_notes', JSON.stringify(notes));
|
const formattedTime = quickNoteLocation.plannedStart
|
||||||
notify({ title: 'Thành công', message: 'Đã lưu vào ghi chú chung!', type: 'success' });
|
? new Date(quickNoteLocation.plannedStart).toLocaleString('vi-VN')
|
||||||
setQuickNoteLocName(null);
|
: "Thời gian tùy hứng";
|
||||||
setQuickNoteInput('');
|
|
||||||
|
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
|
// Hàm xử lý xóa Tour vĩnh viễn
|
||||||
@@ -2088,7 +2104,7 @@ export const TourDetailPage = ({
|
|||||||
|
|
||||||
setIsAddLocationOpen(true);
|
setIsAddLocationOpen(true);
|
||||||
}}
|
}}
|
||||||
onQuickNote={(locName: string) => handleQuickNote(locName)}
|
onQuickNote={(data) => handleQuickNote(data)}
|
||||||
onSuccess={() => fetchTour(tourId)}
|
onSuccess={() => fetchTour(tourId)}
|
||||||
isPublicView={isPublicView}
|
isPublicView={isPublicView}
|
||||||
onNavigate={handleNavigateToLocation}
|
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="text-[10px] text-gray-400 mb-2 uppercase tracking-widest">{loc.type}</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleQuickNote(loc.name)}
|
onClick={() => {
|
||||||
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"
|
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 || {} });
|
||||||
<FileText className="w-3 h-3" />
|
}}
|
||||||
GHI CHÚ NHANH
|
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"
|
||||||
</button>
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
GHI CHÚ NHANH
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCommentLocationId(loc.id);
|
setCommentLocationId(loc.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user